feat(ui): 기둥·말뚝 통합 입력 · 현장타설 화면 · 헤더 한 줄 정리
- 기둥·말뚝 입력을 한 카드로 통합(공통 입력, 구간별 예외 보존, 횡철근 표) - 현장타설 실배근 · 삽도 · 수량을 화면에 연결하고 프로젝트 저장 · 복원 - 상단 헤더를 한 줄로 축약(상태 축약, 파일 메뉴, 말줄임) - 디자인 검토 반영(가져오기 범위, ③ 모델 높이 · 접기, 검토 항목 이동) - 화면 검증 스크립트(verify-unified-input · verify-design-fixes 등) 보관 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e0bfb6fc8
commit
da7b13a568
10 files changed
+1067
-95
No files matched your search
@@ -1,6 +1,14 @@
|
||||
# PIERZAINER · PIER & ABUT
|
||||
|
||||
교각 Excel을 읽어 형상을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
|
||||
교각·교대 Excel을 읽어 형상과 철근배근을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
|
||||
|
||||
## 현재 작업 상태 (2026-11-05 확인)
|
||||
|
||||
- 현재 로컬 작업 브랜치는 `master`이며 제품 코드 기준 커밋은 `91c4a4a`입니다. 아래 11월 2일 복구 기록은 당시 이력입니다.
|
||||
- 교대 ② 형상계획 카드 6개와 ③ 철근배근 6개 부재(받침블럭·벽체·흉벽/브라켓·확대기초·날개벽·접속슬래브)가 구현되어 있습니다.
|
||||
- A1 표본으로 ③ 입력 재계산·삽도/3D 표시·철근 선택·파일 다운로드·새 페이지 저장 복원을 검증했습니다. 설계 미결 사항과 다른 입력의 동작은 별도 확인 대상입니다.
|
||||
- 최신 진행 기록은 `../../context-migration/컨텍스트_2026-11-03_교각_교대_진행상황.md`, 남은 항목은 `../../context-migration/OPEN_미해결목록.md`에서 관리합니다.
|
||||
- 로컬 `origin/master` 추적 정보 대비 89커밋 앞선 상태였습니다. 실제 원격은 조회하지 않았습니다. 브랜치 전환이나 이력 변경 전에 현재 상태를 확인하세요.
|
||||
|
||||
## 실행
|
||||
|
||||
@@ -22,8 +30,25 @@ npm run dev -- --host 127.0.0.1 --port 5173
|
||||
|
||||
`node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다.
|
||||
|
||||
## 2026-11-02 혼합 파일 복구
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
npm test -- --maxWorkers=2
|
||||
npm run build
|
||||
```
|
||||
|
||||
A1 화면 검증 스크립트는 `scripts/verify-abut-workflow.cjs`입니다. 개발 서버와 별도 프로필 Chrome의 로컬 CDP 포트(기본 9444)가 필요합니다.
|
||||
|
||||
현장타설 실배근의 현행 UI 검증은 `node scripts/verify-cast-rebar.cjs`를 사용합니다. Windows Chrome 사용 시 Windows Node로 실행합니다. 샘플 Excel에서 9개 교각·기초 조합을 생성하고 실제 주철근/횡철근/커플러 행·삽도·입력 재생성·불일치 경고·재료표·파일 저장/새 페이지 복원·헤더 3개 폭을 검사합니다. 결과는 `.verify/cast-rebar-20261106/`에 저장합니다. `verify-unified-input.cjs`는 실배근 연결 전 통합 입력 단계의 검증이고, `verify-cast-input.cjs`와 `verify-cast-detail.cjs`는 통합 전 분리 카드 선택자를 사용한 과거 검증 스크립트입니다.
|
||||
|
||||
```text
|
||||
node scripts/verify-abut-workflow.cjs <input.xlsx> <output-directory> <download-directory>
|
||||
```
|
||||
|
||||
Windows Chrome을 쓰면 Windows Node로 실행하고 입력·출력 경로도 Windows 경로 또는 WSL UNC 경로로 전달합니다. 결과 JSON·화면 캡처·SVG·프로젝트 표본은 `.verify/abut-workflow-20261105/`에 보관했습니다. `report.json`의 실행 결과와 범위를 확인하세요. 단순 재열기 검사는 같은 인자 뒤에 `--resume`을 붙일 수 있습니다.
|
||||
|
||||
## 2026-11-02 혼합 파일 복구 (당시 기록)
|
||||
|
||||
혼합 전 Pierzainer 커밋 `d6735a6`에서 로컬 브랜치 `recovery/pierzainer-20261102`를 만들었습니다. π형 Excel 인식, 전·후열 받침 수 불일치 허용, 랜딩페이지 샘플 연결을 포함합니다. 기존 ITAM 브랜치와 원격 기록은 변경하지 않았습니다.
|
||||
|
||||
복구본과 분리한 파일은 상위 `_recovery/20261102_mixed_projects/`에 보관했습니다. 기존 `master`는 ITAM 코드이므로 그 브랜치로 전환하면 화면과 파일이 다시 바뀝니다. 현재 복구 브랜치에서 작업하세요. 이 작업에서는 원격 push를 수행하지 않았습니다.
|
||||
당시 복구 기록에는 분리 파일을 상위 `_recovery/20261102_mixed_projects/`에 보관하고, 당시 `master`가 ITAM 코드였다고 적혀 있습니다. 2026-11-05 현재 로컬 `master`는 Pierzainer 코드(`91c4a4a`)이므로 과거 안내에 따라 복구 브랜치로 전환하지 마세요. 당시 복구 보관 경로의 현존 여부는 이번 작업에서 재확인하지 않았습니다. 이번 검증·기록 정리에서도 원격 push와 브랜치 전환은 수행하지 않았습니다.
|
||||
+40
-23
@@ -11,22 +11,26 @@
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<strong>Pierzainer</strong>
|
||||
<span>교각 설계</span>
|
||||
<span id="project-line" class="proj">프로젝트 미지정</span>
|
||||
</div>
|
||||
<nav class="steps" aria-label="단계">
|
||||
<button id="nav-shape" class="step current" type="button" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></button>
|
||||
<button id="nav-plan" class="step" type="button">② 형상계획 <em id="step2-status">예정</em></button>
|
||||
<button id="nav-rebar" class="step" type="button">③ 철근배근 <em id="step3-status">예정</em></button>
|
||||
<button id="nav-quantity" class="step" type="button">④ 재료표 <em id="step4-status">예정</em></button>
|
||||
<button id="nav-shape" class="step current" data-label="① 형상 생성" aria-label="① 형상 생성" type="button" aria-current="step">① 형상 <em id="step1-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-quantity" class="step" data-label="④ 재료표" aria-label="④ 재료표" type="button">④ 재료 <em id="step4-status"></em></button>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<span id="save-status" class="savest">저장 전</span>
|
||||
<button id="home-from-app" class="btn-s" type="button" hidden>홈으로</button>
|
||||
<button id="home-from-app" class="btn-s" type="button" hidden>홈</button>
|
||||
<button id="save-project" class="btn-s" type="button" disabled title="프로젝트 파일 저장 (Ctrl+S)">저장</button>
|
||||
<button id="open-import" class="btn-s" type="button">엑셀 불러오기</button>
|
||||
<button id="open-project" class="btn-s" type="button">프로젝트 열기</button>
|
||||
<button id="regen" class="btn-p" type="button" disabled>선택 교각 형상 생성</button>
|
||||
<details id="file-menu" class="file-menu">
|
||||
<summary class="btn-s">불러오기</summary>
|
||||
<div class="file-menu-items">
|
||||
<button id="open-import" class="btn small" type="button">엑셀 불러오기</button>
|
||||
<button id="open-project" class="btn small" type="button">프로젝트 열기</button>
|
||||
</div>
|
||||
</details>
|
||||
<button id="regen" class="btn-p" type="button" disabled>생성</button>
|
||||
<input id="project-file" type="file" accept=".json" hidden />
|
||||
</div>
|
||||
</header>
|
||||
@@ -288,7 +292,16 @@
|
||||
<ul id="rebar-tabs" class="members"></ul>
|
||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||
</aside>
|
||||
<div class="center-col">
|
||||
<div class="center-col review-workspace" id="review-workspace">
|
||||
<div class="workspace-tools" aria-label="검토 공간 조절">
|
||||
<label class="model-height-control" for="review-model-height">모델 높이
|
||||
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
|
||||
<output id="review-model-height-value" for="review-model-height">240 px</output>
|
||||
</label>
|
||||
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
|
||||
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
|
||||
<button id="review-table" class="btn small" type="button">철근표 보기</button>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-rebar"></div>
|
||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
||||
@@ -299,24 +312,26 @@
|
||||
<aside class="panel results" aria-labelledby="rebar-issues-title">
|
||||
<h2 id="rebar-issues-title">알림</h2>
|
||||
<ul id="rebar-issues" class="issues"></ul>
|
||||
<h2>검토 필요 (결정 대기)</h2>
|
||||
<ul class="issues">
|
||||
<li>Q-B5 B1·B3·B5·B7 다리 길이 (제안값 사용)</li>
|
||||
<li>Q-B6 B2·B6 이음 위치와 길이</li>
|
||||
<li>Q-C9 P4–B11 간섭: 무시로 결정, 문서 반영 필요</li>
|
||||
</ul>
|
||||
<h2>부재 검토</h2>
|
||||
<p id="review-summary" class="muted small" aria-live="polite"></p>
|
||||
<ul id="review-tasks" class="issues review-tasks"></ul>
|
||||
<section class="common" aria-labelledby="common-title">
|
||||
<p id="common-title" class="lbl">공통 설정</p>
|
||||
<h3 id="common-title">공통 설정</h3>
|
||||
<fieldset class="material-fields">
|
||||
<legend>재료</legend>
|
||||
<label class="mat">fck (MPa) <input id="mat-fck" type="number" step="any" /></label>
|
||||
<label class="mat">D 규격 강도 <select id="mat-grade-d"><option value="300" selected>SD300</option><option value="400">SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">H 규격 강도 <select id="mat-grade-h"><option value="300">SD300</option><option value="400" selected>SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">S 규격 강도 <select id="mat-grade-s"><option value="300">SD300</option><option value="400">SD400</option><option value="500" selected>SD500</option></select></label>
|
||||
<label class="mat">재료표 할증 (%) · 기본 0 가정 <input id="mat-waste" type="number" step="0.5" min="0" /></label>
|
||||
<p class="lbl">굽힘 내면반경 (× 철근 지름)</p>
|
||||
</fieldset>
|
||||
<fieldset class="material-fields">
|
||||
<legend>굽힘 내면반경 (× 철근 지름)</legend>
|
||||
<label class="mat">D16 이하 <input id="bend-small16" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D19–D25 <input id="bend-main25" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D29–D35 <input id="bend-main35" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D38 이상 <input id="bend-main51" type="number" step="0.5" min="0.5" /></label>
|
||||
</fieldset>
|
||||
<p id="mat-note" class="muted small"></p>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -389,20 +404,22 @@
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>미리보기</h3>
|
||||
<p id="preview-title" class="muted"></p>
|
||||
<p id="preview-title" class="muted" aria-live="polite"></p>
|
||||
<h4>교각 정보</h4>
|
||||
<div class="pv-scroll"><table id="pv-pier" class="grid-table"></table></div>
|
||||
<h4>교대 정보</h4>
|
||||
<div class="pv-scroll"><table id="pv-abuts" class="grid-table"></table></div>
|
||||
<h4>받침</h4>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-bearings" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<thead><tr><th>구조물</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4>IP</h4>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-ips" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<thead><tr><th>구조물</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -411,9 +428,9 @@
|
||||
</div>
|
||||
|
||||
<footer class="dlg-foot">
|
||||
<span id="dlg-selected" class="muted"></span>
|
||||
<span id="dlg-selected" class="muted" aria-live="polite"></span>
|
||||
<button id="dlg-cancel" class="btn" type="button">취소</button>
|
||||
<button id="dlg-apply" class="btn primary" type="button" disabled>선택한 교각 0기 형상 생성</button>
|
||||
<button id="dlg-apply" class="btn primary" type="button" disabled>불러올 구조물을 선택하세요</button>
|
||||
</footer>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Read the user's linked artifact in an isolated browser profile, without changing its content.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const out = path.resolve(__dirname, '../.verify/design-system-artifact-20261105');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
async function inspect(target, name, screenshot) {
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const message = JSON.parse(data);
|
||||
const wait = pending.get(message.id);
|
||||
if (!wait) return;
|
||||
clearTimeout(wait.timer); pending.delete(message.id);
|
||||
if (message.error) wait.reject(new Error(JSON.stringify(message.error))); else wait.resolve(message.result);
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 15000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
try {
|
||||
await send('Runtime.enable');
|
||||
if (screenshot) {
|
||||
await send('Page.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
}
|
||||
const text = await send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true });
|
||||
const html = await send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true });
|
||||
const frames = await send('Runtime.evaluate', { expression: '[...document.querySelectorAll("iframe")].map(f=>({src:f.src,title:f.title,srcdocLength:f.srcdoc.length}))', returnByValue: true });
|
||||
fs.writeFileSync(path.join(out, `${name}.txt`), text.result?.value || '');
|
||||
fs.writeFileSync(path.join(out, `${name}.html`), html.result?.value || '');
|
||||
fs.writeFileSync(path.join(out, `${name}-frames.json`), JSON.stringify(frames.result?.value, null, 2));
|
||||
console.log(JSON.stringify({ name, title: target.title, url: target.url, text: (text.result?.value || '').slice(0, 10000), frames: frames.result?.value }));
|
||||
if (screenshot) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, 'overview.png'), Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
} finally { ws.close(); }
|
||||
}
|
||||
(async () => {
|
||||
let targets = await (await fetch('http://127.0.0.1:9444/json')).json();
|
||||
const page = targets.find((target) => target.type === 'page' && target.url.includes('9LPwY9MMugHfvF5auhpVW2'));
|
||||
if (!page) throw new Error('Linked artifact page not found');
|
||||
await inspect(page, 'page', true);
|
||||
targets = await (await fetch('http://127.0.0.1:9444/json')).json();
|
||||
let index = 0;
|
||||
for (const target of targets.filter((target) => target.type === 'iframe')) await inspect(target, `frame-${++index}`, false);
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,21 @@
|
||||
// Read focused-control and native-menu behaviour in the last verification tab.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
(async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9444/json/list')).json();
|
||||
const page = pages.find(p=>p.url==='http://127.0.0.1:5173/');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise(r=>ws.onopen=r);
|
||||
let seq=0; const pending=new Map();
|
||||
ws.onmessage=({data})=>{const m=JSON.parse(data);if(pending.has(m.id)){pending.get(m.id)(m.result);pending.delete(m.id);}};
|
||||
const send=(method,params={})=>new Promise(r=>{const id=++seq;pending.set(id,r);ws.send(JSON.stringify({id,method,params}));});
|
||||
const ev=async expression=>(await send('Runtime.evaluate',{expression,returnByValue:true})).result.value;
|
||||
const inspect=`({open:document.getElementById('file-menu').open,active:document.activeElement.outerHTML,summaryTab:document.querySelector('#file-menu summary').tabIndex})`;
|
||||
const result={before:await ev(inspect)};
|
||||
await ev(`document.querySelector('#file-menu summary').focus()`);
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',text:'\r',windowsVirtualKeyCode:13});
|
||||
await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13});
|
||||
result.after=await ev(inspect);
|
||||
fs.writeFileSync(path.resolve(__dirname,'../.verify/unified-input-20261106/keyboard-diagnosis.json'),JSON.stringify(result,null,2));
|
||||
console.log(result);ws.close();
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Read-only design sample capture, fresh isolated CDP target. Run via Windows Node.
|
||||
const fs=require('node:fs'), path=require('node:path');
|
||||
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/impeccable-20261105/a-after');
|
||||
fs.mkdirSync(out,{recursive:true});
|
||||
// Preserve the first animation-timed sample before the one allowed corrective batch.
|
||||
if(fs.existsSync(path.join(out,'raw.json'))){const archive=path.join(out,'initial');fs.mkdirSync(archive,{recursive:true});for(const f of fs.readdirSync(out))if(/\.(png|json)$/.test(f))fs.copyFileSync(path.join(out,f),path.join(archive,f));}
|
||||
const report={started:new Date().toISOString(),views:[],errors:[]};
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
|
||||
(async()=>{
|
||||
const target=await(await fetch('http://127.0.0.1:9444/json/new?'+encodeURIComponent('http://127.0.0.1:5173/'),{method:'PUT'})).json();report.target=target.id;
|
||||
ws=new WebSocket(target.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j});
|
||||
let id=0;const pending=new Map();ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);if(p){pending.delete(m.id);clearTimeout(p.t);m.error?p.j(Error(JSON.stringify(m.error))):p.r(m.result)}}else if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params)};
|
||||
const send=(method,params={})=>new Promise((r,j)=>{const n=++id,t=setTimeout(()=>j(Error('Timeout '+method)),20000);pending.set(n,{r,j,t});ws.send(JSON.stringify({id:n,method,params}))});
|
||||
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value};
|
||||
const until=async(expression)=>{for(let n=0;n<100;n++){if(await ev(expression))return;await delay(200)}throw Error('Wait failed '+expression)};
|
||||
const click=selector=>ev(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing ${selector}');e.click()})()`);
|
||||
const cap=async name=>{await delay(600);fs.writeFileSync(path.join(out,name+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));const facts=await ev(`({text:document.body.innerText,viewport:{width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight},controls:[...document.querySelectorAll('button,input,select,summary')].filter(e=>e.getBoundingClientRect().width&&e.getBoundingClientRect().height).map(e=>({tag:e.tagName,id:e.id,text:e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder'),r:(()=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})(),font:getComputedStyle(e).fontSize}))})`);report.views.push({name,...facts});fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2))};
|
||||
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 until(`!!document.querySelector('[data-card="excel"] button')`);await delay(2500);
|
||||
await click('[data-card="excel"] button');
|
||||
await ev(`document.getElementById('project-name').value='디자인 검토 A';document.getElementById('project-name').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||
const doc=await send('DOM.getDocument'),node=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
|
||||
await send('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[path.resolve(root,'../../RAG/운곡교P1입력.xlsx')]});
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);await cap('01-import-1600');
|
||||
report.importBefore=await ev(`({scope:document.querySelector('#dlg-selected').textContent,preview:document.querySelector('#pv-abut')?.innerText,button:document.querySelector('#dlg-apply').textContent})`);
|
||||
await click('#import-abut-A1');report.importDeselected=await ev(`({scope:document.querySelector('#dlg-selected').textContent,button:document.querySelector('#dlg-apply').textContent,preview:document.querySelector('#pv-abut')?.innerText})`);await click('#import-abut-A1');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('02-import-1280');
|
||||
await click('#dlg-apply');await until(`!document.getElementById('import-dialog').open`);
|
||||
await click('#nav-plan');await until(`[...document.querySelectorAll('#plan-pier-items button')].some(b=>b.textContent.includes('A1')&&b.textContent.includes('교대'))`);
|
||||
await ev(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('A1')&&b.textContent.includes('교대')).click()`);await delay(4500);
|
||||
await click('#nav-rebar');await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
if(await ev(`document.querySelector('#review-model-toggle').getAttribute('aria-expanded')==='false'`))await click('#review-model-toggle');
|
||||
await ev(`document.querySelector('#review-model-height').value='240';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);await cap('03-rebar-1600');
|
||||
report.footTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('04-rebar-1280');
|
||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('날개벽')).click()`);
|
||||
report.wingTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
|
||||
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
|
||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
|
||||
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
|
||||
await click('#review-model-toggle');await click('#review-table');
|
||||
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
|
||||
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
||||
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
await send('Page.close');ws.close();
|
||||
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
|
||||
@@ -0,0 +1,52 @@
|
||||
// Independent design evidence: only the newly created Chrome tab is modified.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const [input, out] = process.argv.slice(2);
|
||||
const report = { views: [], console: [], exceptions: [], started: new Date().toISOString() };
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws;
|
||||
async function main() {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
|
||||
report.targetId = target.id;
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r,j) => {ws.onopen=r; ws.onerror=j;});
|
||||
let next=0; const pending=new Map();
|
||||
ws.onmessage=({data})=> {const m=JSON.parse(data); if(m.id){const p=pending.get(m.id);if(p){clearTimeout(p.t);pending.delete(m.id);m.error?p.j(m.error):p.r(m.result);}} else if(m.method==='Runtime.consoleAPICalled')report.console.push({type:m.params.type,args:m.params.args.map(x=>x.value??x.description)});else if(m.method==='Runtime.exceptionThrown')report.exceptions.push(m.params.exceptionDetails);};
|
||||
const send=(method,params={})=>new Promise((r,j)=>{const id=++next;const t=setTimeout(()=>j(new Error(method+' timeout')),20000);pending.set(id,{r,j,t});ws.send(JSON.stringify({id,method,params}));});
|
||||
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
|
||||
const until=async e=>{for(let n=0;n<100;n++){if(await ev(e))return;await sleep(150);}throw new Error('Missing state '+e);};
|
||||
const click=async e=>ev(`(()=>{const n=${e};if(!n||n.disabled)throw new Error('Missing/disabled click');n.click();return true;})()`);
|
||||
await send('Runtime.enable'); await send('Page.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await until(`typeof window.loadBytesToApp==='function'`);
|
||||
report.injectionPreflight=await ev(`(()=>{document.title='[Human] Design evidence B';let s=document.createElement('script');s.textContent='window.__designBMutation=true';document.head.append(s);return window.__designBMutation===true;})()`);
|
||||
const measure=async name=>{
|
||||
if(process.env.OVERLAY_URL) report.overlayInjection=await ev(`new Promise(resolve=>{document.title='[Human] Design evidence B';scrollTo(0,0);const s=document.createElement('script');s.src=${JSON.stringify(process.env.OVERLAY_URL)};s.onload=()=>setTimeout(()=>resolve({loaded:true}),2600);s.onerror=()=>resolve({loaded:false});document.head.append(s);})`);
|
||||
await sleep(350);
|
||||
const facts=await ev(`(()=>{const visible=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&getComputedStyle(e).visibility!=='hidden'};const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}};const controls=[...document.querySelectorAll('button,input,select,textarea')].filter(visible);const fields=controls.filter(e=>/INPUT|SELECT|TEXTAREA/.test(e.tagName));const named=e=>!!(e.labels?.length||e.getAttribute('aria-label')||e.getAttribute('aria-labelledby')||e.title);return {viewport:{w:innerWidth,h:innerHeight},doc:{w:document.documentElement.scrollWidth,h:document.documentElement.scrollHeight},text:document.body.innerText,controls:controls.map(e=>({tag:e.tagName,id:e.id,text:(e.innerText||e.getAttribute('aria-label')||e.value||'').slice(0,90),...rect(e),font:getComputedStyle(e).fontSize,disabled:e.disabled})),unnamedFields:fields.filter(e=>!named(e)).map(e=>({id:e.id,type:e.type,...rect(e)})),overflow:[...document.querySelectorAll('body *')].filter(visible).filter(e=>e.scrollWidth>e.clientWidth+2&&e.clientWidth>0).map(e=>({tag:e.tagName,id:e.id,cls:e.className,client:e.clientWidth,scroll:e.scrollWidth,overflowX:getComputedStyle(e).overflowX,...rect(e)})).slice(0,40),active:{id:document.activeElement.id,tag:document.activeElement.tagName},dialogs:[...document.querySelectorAll('dialog[open]')].map(rect)};})()`);
|
||||
facts.name=name;report.views.push(facts);
|
||||
const shot=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(shot.data,'base64'));
|
||||
};
|
||||
await measure('01-landing-desktop');
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value='디자인 독립 검증 B'`);
|
||||
const doc=await send('DOM.getDocument');const file=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
|
||||
await send('DOM.setFileInputFiles',{nodeId:file.nodeId,files:[input]});
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||
await measure('02-import-desktop');
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await click(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
|
||||
await measure('03-A1-plan-desktop');
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
|
||||
await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
|
||||
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('벽체'))`);
|
||||
await measure('04-A1-rebar-desktop');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});
|
||||
await measure('05-A1-rebar-laptop');
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});
|
||||
report.keyboardFocus=await ev(`(()=>{const e=document.activeElement;const s=getComputedStyle(e);return {tag:e.tagName,id:e.id,text:e.innerText?.slice(0,80),outline:s.outline,focusVisible:e.matches(':focus-visible')}})()`);
|
||||
report.result='COMPLETE';
|
||||
}
|
||||
fs.mkdirSync(out,{recursive:true});main().catch(e=>{report.result='PARTIAL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'browser.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:report.result,views:report.views.length,failure:report.failure}));ws?.close();});
|
||||
@@ -0,0 +1,130 @@
|
||||
// CDP regression checks for the four scoped design improvements. Run with Windows Node.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const out = path.join(root, '.verify', 'design-fixes-20261105');
|
||||
const report = { checks: [], errors: [], captures: [], started: new Date().toISOString() };
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
let ws;
|
||||
let send;
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function main() {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id) {
|
||||
const wait = pending.get(msg.id);
|
||||
if (!wait) return;
|
||||
clearTimeout(wait.timer); pending.delete(msg.id);
|
||||
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
|
||||
} else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
|
||||
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
};
|
||||
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 < 100; i++) { if (await ev(expression)) return; await delay(150); }
|
||||
throw new Error(`Timeout: ${expression}`);
|
||||
};
|
||||
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled control'); e.click(); })()`);
|
||||
const shot = async (name) => {
|
||||
await delay(200);
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
|
||||
report.captures.push(name);
|
||||
};
|
||||
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const upload = async () => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [] });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
|
||||
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
|
||||
};
|
||||
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
|
||||
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
|
||||
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 until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value = '디자인 개선 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
|
||||
await upload();
|
||||
check('combined import scope', await ev(`document.getElementById('dlg-apply').textContent === '교각 1기 + 교대 1기 가져오기'`));
|
||||
check('A1 source and dimensions preview', await ev(`document.getElementById('pv-abuts').textContent.includes('A1') && document.getElementById('pv-abuts').textContent.includes('12.197') && document.querySelector('#sheets > li:first-child').textContent.includes('교대 인식 결과')`));
|
||||
await shot('01-import-both');
|
||||
await click(`document.getElementById('import-abut-A1')`);
|
||||
check('uncheck updates preview and count', await ev(`document.getElementById('dlg-apply').textContent.includes('교대 0기') && document.getElementById('pv-abuts').textContent.includes('아니오')`));
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
check('unchecked abutment is not applied', await ev(`![...document.querySelectorAll('#pier-items button')].some(b => b.textContent.includes('교대'))`));
|
||||
await click(`document.getElementById('open-import')`);
|
||||
await upload();
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('A1') && b.textContent.includes('교대'))`);
|
||||
await member('확대기초');
|
||||
check('A1 footing excludes pier review questions', await ev(`document.getElementById('review-summary').textContent.includes('A1') && document.getElementById('review-summary').textContent.includes('미결 0건') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
|
||||
await delay(4500); await shot('02-workspace-desktop');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
|
||||
await delay(300);
|
||||
if (await ev(`document.getElementById('review-model-toggle').getAttribute('aria-expanded') === 'false'`)) await click(`document.getElementById('review-model-toggle')`);
|
||||
await ev(`const size = document.getElementById('review-model-height'); size.value = size.max; size.dispatchEvent(new Event('input'));`);
|
||||
await delay(200);
|
||||
report.layoutBefore = await lower();
|
||||
await ev(`const range = document.getElementById('review-model-height'); range.value = '180'; range.dispatchEvent(new Event('input'));`);
|
||||
await delay(200);
|
||||
report.layoutResized = await lower();
|
||||
check('height control expands evidence area', report.layoutResized >= report.layoutBefore);
|
||||
check('compact viewer tools stay inside model', await ev(`document.querySelector('.viewer-stack-right').getBoundingClientRect().bottom <= document.getElementById('viewer-block').getBoundingClientRect().bottom + 1`));
|
||||
await click(`document.getElementById('review-model-toggle')`);
|
||||
await delay(200);
|
||||
report.layoutCollapsed = await lower();
|
||||
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
|
||||
await click(`document.getElementById('review-table')`);
|
||||
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
|
||||
await shot('03-table-laptop');
|
||||
await click(`document.getElementById('review-inputs')`);
|
||||
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
|
||||
await shot('04-inputs-laptop');
|
||||
check('layout preference persisted', await ev(`JSON.parse(localStorage.getItem('pierzainer.review-layout')).collapsed === true`));
|
||||
await click(`document.getElementById('review-model-toggle')`);
|
||||
await member('받침블럭');
|
||||
check('block review is scoped', await ev(`document.getElementById('review-tasks').textContent.includes('Q-S4') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
|
||||
await click(`document.querySelector('#review-tasks button')`);
|
||||
check('review action selects correct bar and row', await ev(`document.querySelector('#rebar-table .rebar-select').textContent.includes('선택 1 /') && document.activeElement.matches('tr') && document.activeElement.textContent.includes('S1')`));
|
||||
await member('날개벽');
|
||||
check('wing review shows only relevant unresolved work', await ev(`document.getElementById('review-summary').textContent.includes('미결 2건') && document.getElementById('review-tasks').textContent.includes('E19') && document.getElementById('review-tasks').textContent.includes('V-A2')`));
|
||||
await shot('05-review-and-materials');
|
||||
report.fields = await ev(`[...document.querySelectorAll('.material-fields .mat')].map(label => { const r = label.getBoundingClientRect(); const c = label.querySelector('input,select').getBoundingClientRect(); return { label: label.textContent, left: r.left, top: r.top, width: r.width, controlLeft: c.left, controlRight: c.right, bottom: r.bottom }; })`);
|
||||
check('material fields align without overlap', report.fields.every((f, i, list) => f.controlRight <= f.left + f.width + 1 && (i === 0 || f.top >= list[i - 1].bottom)));
|
||||
check('document has no horizontal overflow at laptop width', await ev('document.documentElement.scrollWidth === innerWidth'));
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('P1') && !b.textContent.includes('교대'))`);
|
||||
await member('기둥');
|
||||
check('existing decision is not presented as unresolved', await ev(`document.getElementById('review-tasks').textContent.includes('Q-C9') && document.getElementById('review-tasks').textContent.includes('결정됨') && document.getElementById('review-summary').textContent.includes('미결 0건')`));
|
||||
check('no runtime errors', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
}
|
||||
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
|
||||
if (report.result === 'FAIL' && send) {
|
||||
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* preserve original failure */ }
|
||||
}
|
||||
report.finished = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
|
||||
if (send) { try { await send('Page.close'); } catch { /* tab may already be closed */ } }
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// Run with Windows Node against an isolated Chrome CDP session (port 9444).
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/unified-input-20261106');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], layouts: [] };
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let ws, send;
|
||||
(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((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0, download;
|
||||
const pending = new Map(), completed = new Set();
|
||||
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);
|
||||
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
|
||||
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
|
||||
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 < 100; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
|
||||
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`);
|
||||
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const set = (id, value) => ev(`(() => {const e=document.getElementById(${JSON.stringify(id)}); e.value=${JSON.stringify(String(value))}; e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`);
|
||||
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
|
||||
const selectPier = name => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`);
|
||||
const open = async name => {
|
||||
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: [name] });
|
||||
};
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const downloads = path.join(out, 'downloads'); fs.mkdirSync(downloads, { recursive: true });
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
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');
|
||||
const {parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||
const bytes=await (await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||
const piers=[];
|
||||
for(const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for(const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
|
||||
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
|
||||
piers.push({id:input.name,input,defaults:input,draft:input});
|
||||
}
|
||||
return {schemaVersion:1,project:{name:'입력 통합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
fixture.project.name = '아주 긴 프로젝트명으로 말줄임과 한 줄 헤더를 검증하는 프로젝트';
|
||||
fixture.project.bridge = fixture.project.name;
|
||||
fixture.piers[0].input.castInPlaceRebar = {
|
||||
column: { mainCount: 46, mainDiameterMm: 32, coverMm: 100, coverReference: 'main-center' },
|
||||
pile: { mainCount: 48, mainDiameterMm: 32, coverMm: 100, coverReference: 'clear', tieKind: 'spiral', tieSpacingMm: 200, cageLengthMm: 12000 },
|
||||
connection: { mode: 'coupler', couplerLengthMm: 165 },
|
||||
};
|
||||
fixture.piers[0].draft = fixture.piers[0].input;
|
||||
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()'); await open(fixturePath);
|
||||
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const cast = [1, 4, 7].includes(i);
|
||||
await selectPier('P'+i); await member('기둥');
|
||||
check(`P${i}: correct foundation-specific member list and editor`, await ev(cast
|
||||
? `document.querySelectorAll('#rebar-tabs button').length===3 && !!document.querySelector('[data-cast-part="combined"]') && ![...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.includes('확대기초'))`
|
||||
: `!!document.getElementById('column-nDiv') && [...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.startsWith('확대기초')) && !document.querySelector('[data-cast-part]')`));
|
||||
}
|
||||
await selectPier('P4'); await member('기둥');
|
||||
check('equal defaults show one shared value with exceptions collapsed', await ev(`!document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='46'`));
|
||||
await set('cip-common-coverMm', 120); await set('cip-common-coverReference', 'clear'); await click(`document.getElementById('cip-apply')`);
|
||||
check('explicit common cover and reference apply to both segments', await ev(`document.getElementById('cip-column-coverMm').value==='120' && document.getElementById('cip-pile-coverMm').value==='120' && document.getElementById('cip-column-coverReference').value==='clear' && document.getElementById('cip-pile-coverReference').value==='clear'`));
|
||||
await selectPier('P1'); await member('기둥');
|
||||
check('unequal counts and equal cover numbers with different references remain distinct', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='' && document.getElementById('cip-common-coverMm').value==='' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48'`));
|
||||
check('one connection editor and one save button', await ev(`document.querySelectorAll('#cip-connection-mode').length===1 && document.querySelectorAll('#cip-apply').length===1`));
|
||||
await set('cip-common-innerMainDiameterMm', 35.8); await click(`document.getElementById('cip-apply')`);
|
||||
check('shared edit updates both segments and preserves unrelated exceptions', await ev(`document.getElementById('cip-column-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48' && document.getElementById('cip-pile-coverReference').value==='clear' && document.getElementById('cip-column-coverReference').value==='main-center'`));
|
||||
await set('cip-pile-cageLengthMm', 999999); await click(`document.getElementById('cip-apply')`);
|
||||
check('invalid cage length is rejected inline', await ev(`document.querySelector('[data-cast-part]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
|
||||
await set('cip-pile-cageLengthMm', 12000); await set('cip-common-mainCount', 46.5); await click(`document.getElementById('cip-apply')`);
|
||||
check('fractional main counts are rejected', await ev(`document.querySelector('[role="status"]').textContent!==undefined && document.querySelector('[data-cast-part]').textContent.includes('양수 정수')`));
|
||||
await set('cip-common-mainCount', 50); await set('cip-pile-mainCount', 52); await set('cip-connection-mode', 'continuous');
|
||||
await click(`document.getElementById('cip-apply')`);
|
||||
check('explicit pile exception overrides a shared edit without changing column', await ev(`document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-tieSpacingMm').value==='200' && document.getElementById('cip-connection-couplerLengthMm').value==='165'`));
|
||||
check('pending review remains visible', await ev(`document.getElementById('review-tasks').textContent.includes('CP-F1') && document.getElementById('nav-rebar').getAttribute('aria-label').includes('현장타설 배근 미확정')`));
|
||||
await shot('01-unified-input');
|
||||
for (const width of [1280,1600,1920]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false }); await delay(200);
|
||||
const layout = await ev(`(() => {const h=document.querySelector('.top'),r=h.getBoundingClientRect();return {width:innerWidth,height:r.height,overflow:document.documentElement.scrollWidth>innerWidth,rows:[...h.children].map(e=>Math.round(e.getBoundingClientRect().top)),project:document.getElementById('project-line').textContent};})()`);
|
||||
report.layouts.push(layout);
|
||||
check(`header ${width}: single row and no document overflow`, layout.height<=65 && !layout.overflow);
|
||||
await shot('header-'+width);
|
||||
}
|
||||
check('duplicate project/bridge name is omitted while full title remains available', await ev(`document.getElementById('project-line').title===${JSON.stringify(fixture.project.name+' · 교각 9기')}`));
|
||||
await click(`document.querySelector('#file-menu summary')`);
|
||||
check('file menu opens', await ev(`document.getElementById('file-menu').open`));
|
||||
await ev(`document.getElementById('open-project').focus()`);
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
check('Escape closes the menu and restores summary focus', await ev(`!document.getElementById('file-menu').open && document.activeElement===document.querySelector('#file-menu summary')`));
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13 });
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
|
||||
check('keyboard Enter opens the native file menu', await ev(`document.getElementById('file-menu').open`));
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
check('Tab reaches the Excel import action', await ev(`document.activeElement.id==='open-import'`));
|
||||
await click(`document.getElementById('open-import')`);
|
||||
check('Excel action opens the existing import dialog and closes the menu', await ev(`!document.getElementById('file-menu').open && !!document.querySelector('dialog[open]')`));
|
||||
await ev(`document.querySelector('dialog[open]').close()`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
|
||||
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
|
||||
assert.ok(download && completed.has(download), 'download completes');
|
||||
const savedText = fs.readFileSync(path.join(downloads,download), 'utf8');
|
||||
const saved = JSON.parse(savedText).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
|
||||
check('download retains independent segment settings and connection schema', saved.column.mainCount===50 && saved.pile.mainCount===52 && saved.pile.coverReference==='clear' && saved.column.coverReference==='main-center' && saved.connection.mode==='continuous' && !saved.common);
|
||||
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); await until(`typeof window.openSampleProject==='function'`);
|
||||
await ev('window.openSampleProject()'); await open(savedPath); await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`); await selectPier('P1'); await member('기둥');
|
||||
check('fresh-page restore retains exceptions and shared changes', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-connection-mode').value==='continuous'`));
|
||||
// Follow dedicated warnings to verify both segment targets resolve to one card.
|
||||
for (const text of ['RB-CIP01', 'RB-CIP02', 'RB-F00']) {
|
||||
await click(`[...document.querySelectorAll('#rebar-issues li')].find(e=>e.textContent.includes(${JSON.stringify(text)}))?.querySelector('button')`);
|
||||
check(`${text}: warning opens unified editor`, await ev(`!!document.getElementById('cip-apply') && document.querySelector('#rebar-tabs button[aria-pressed="true"]').textContent.startsWith('기둥·말뚝')`));
|
||||
}
|
||||
await click(`document.getElementById('nav-quantity')`);
|
||||
check('quantity remains explicitly incomplete', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`));
|
||||
check('no runtime exceptions', report.errors.length===0);
|
||||
report.result='PASS';
|
||||
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(async()=>{
|
||||
if(report.result==='FAIL' && send) try {const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64'));}catch{}
|
||||
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify({result:report.result,checks:report.checks.length,failure:report.failure}));
|
||||
ws?.close();
|
||||
});
|
||||
+501
-69
@@ -5,6 +5,7 @@ import { swapXYAzimuth } from './core/coordinate-frame';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
|
||||
import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse';
|
||||
import { groupAbutImportIssues } from './core/abut/import-issues';
|
||||
import { buildAbut } from './core/abut/geometry';
|
||||
import { structureOrder } from './core/abut/order';
|
||||
import { ABUT_DETAIL_LABELS, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary';
|
||||
@@ -98,9 +99,13 @@ import { blockRebarPlanSvg } from './view/block-rebar-plan-svg';
|
||||
import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg';
|
||||
import { BLOCK_REBAR_RANGES } from './core/rebar/block-check';
|
||||
import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block';
|
||||
import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings';
|
||||
import { COPING_REBAR_RANGES, copingDefaults, copingSettingsIssues, type CopingRebarSettings, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings';
|
||||
import { COLUMN_REBAR_RANGES, type ColumnRebarSettings, columnSettingsIssues, DEFAULT_COLUMN_REBAR } from './core/rebar/column-settings';
|
||||
import { columnInputFor, columnRebar } from './core/rebar/column';
|
||||
import { CAST_PART_FIELDS, castSettingsIssues, castRebarDefaults, type CastInPlaceRebar, type CastRebarPart } from './core/rebar/cast-in-place';
|
||||
import { castRebarLayout } from './core/rebar/cast-layout';
|
||||
import { castRebarSvg } from './view/cast-rebar-svg';
|
||||
import { castShape, castShapeIssues, castLowerZone, type CastInPlaceShapeSettings } from './core/cast-in-place-shape';
|
||||
import { DEFAULT_FOOTING_REBAR, FOOTING_REBAR_RANGES, type FootingRebarSettings, footingSettingsIssues } from './core/rebar/footing-settings';
|
||||
import { footingRuleValues } from './core/rebar/footing';
|
||||
import { footingSectionSvg } from './view/footing-section-svg';
|
||||
@@ -182,6 +187,7 @@ interface Pending {
|
||||
/** 교대 기록 read from the same workbook (AB). */
|
||||
abutments: AbutRecord[];
|
||||
abutIssues: Issue[];
|
||||
selectedAbutments: Set<string>;
|
||||
}
|
||||
|
||||
const state = {
|
||||
@@ -686,10 +692,6 @@ function setupScene(): void {
|
||||
const pier = activePier();
|
||||
// In ② an abutment selection draws the abutment alone, so the pier scene is not built (visibility.ts).
|
||||
if (pier?.model && piersShown(state.stage, state.abutActive)) {
|
||||
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
||||
rebar3d.group.visible = state.stage === 'rebar';
|
||||
rebar3d.setColorMode(state.rebarColor);
|
||||
world.add(rebar3d.group);
|
||||
const importedPier = groundModel?.piers.find((q) => q.id === pier.input.name);
|
||||
const importedGroundEl = groundModel && importedPier ? importedPier.centerEl : undefined;
|
||||
const scene = buildPierScene(pier.model, groundModel?.terrainGrid, importedGroundEl);
|
||||
@@ -701,6 +703,16 @@ function setupScene(): void {
|
||||
setRebarView(scene, state.stage === 'rebar');
|
||||
state.scene = scene;
|
||||
setView(state.view);
|
||||
if (state.stage === 'rebar') {
|
||||
try {
|
||||
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
||||
rebar3d.setColorMode(state.rebarColor);
|
||||
world.add(rebar3d.group);
|
||||
} catch (err) {
|
||||
setRebarView(scene, false);
|
||||
setError(`철근 3D 생성 실패 · 콘크리트 형상은 유지합니다: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ③ 교대 (2026-11-04): with the piers hidden, the selected abutment's 저판 · 벽체 · 흉벽 · 날개벽 · 접속슬래브 · 받침블럭 rebar is the ③ rebar (표 · 3D 선택 공통).
|
||||
const abutRec = piersShown(state.stage, state.abutActive) ? undefined : state.abutments.find((a) => a.name === state.abutActive);
|
||||
@@ -1942,7 +1954,7 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
|
||||
const badge = mode === 'plan'
|
||||
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
|
||||
: mode === 'rebar' || mode === 'quantity'
|
||||
? (pier.model ? el('span', '계산됨', 'badge') : el('span', '오류', 'badge error'))
|
||||
? (pier.model ? el('span', pier.input.foundationType === '현장타설말뚝' || pier.model.columns.length > 1 ? '일부 계산' : '계산됨', 'badge') : el('span', '오류', 'badge error'))
|
||||
: pier.model
|
||||
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
|
||||
: el('span', '오류', 'badge error');
|
||||
@@ -1956,20 +1968,30 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setHeaderStatus(id: string, short: string, detail: string): void {
|
||||
const status = $(id);
|
||||
status.textContent = short;
|
||||
const button = status.parentElement!;
|
||||
button.title = detail;
|
||||
button.setAttribute('aria-label', `${button.dataset.label} · ${detail}`);
|
||||
}
|
||||
|
||||
function renderHeader(): void {
|
||||
const pier = activePier();
|
||||
const parts = [state.project.name || '프로젝트 미지정'];
|
||||
if (state.project.bridge) parts.push(state.project.bridge);
|
||||
if (state.project.bridge && state.project.bridge !== state.project.name) parts.push(state.project.bridge);
|
||||
parts.push(`교각 ${state.piers.length}기`);
|
||||
$('project-line').textContent = parts.join(' · ');
|
||||
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
|
||||
$('project-line').title = parts.join(' · ');
|
||||
setHeaderStatus('step1-status', '', pier?.model ? '생성됨' : '미생성');
|
||||
renderSaveStatus();
|
||||
const total = pendingTotal();
|
||||
const targets = generationTargets();
|
||||
$('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정';
|
||||
setHeaderStatus('step2-status', total ? `변경·${total}` : '', total ? `변경 ${total}건 · 재생성 필요` : '변경 없음');
|
||||
const regen = $<HTMLButtonElement>('regen');
|
||||
regen.disabled = targets.length === 0;
|
||||
regen.textContent = targets.length ? `변경 교각 다시 생성 · ${targets.length}기` : '교각 형상 생성';
|
||||
regen.textContent = targets.length ? `재생성·${targets.length}` : '생성';
|
||||
regen.title = targets.length ? `변경 교각 다시 생성 · ${targets.length}기` : '교각 형상 생성';
|
||||
renderCopyPanel();
|
||||
}
|
||||
|
||||
@@ -2127,6 +2149,7 @@ function renderAll(): void {
|
||||
renderEditCard();
|
||||
renderPlan();
|
||||
renderRebar();
|
||||
renderReviewTasks();
|
||||
renderRebarColor();
|
||||
if (state.stage === 'quantity') renderQuantityScreen();
|
||||
scheduleBackup();
|
||||
@@ -2341,8 +2364,11 @@ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { over
|
||||
input.type = 'number';
|
||||
input.step = 'any';
|
||||
input.value = String(pier.draft[f.key]);
|
||||
const inactive = pier.input.foundationType === '현장타설말뚝' && ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileOuterMm', 'pileThicknessMm'].includes(f.key);
|
||||
input.disabled = inactive;
|
||||
input.addEventListener('change', () => editField(pier.input.name, f.key, input.value));
|
||||
row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small'));
|
||||
if (inactive) row.appendChild(el('span', '현장타설말뚝 형식: 적용 안 함', 'muted small'));
|
||||
if (changed) {
|
||||
const revert = el('button', '되돌리기', 'btn small');
|
||||
revert.type = 'button';
|
||||
@@ -2473,6 +2499,10 @@ function setPileRow(name: string, row: number, delta: number): void {
|
||||
function renderShapeOptions(member: MemberId, pier?: Pier): void {
|
||||
const box = $<HTMLDivElement>('member-options');
|
||||
box.replaceChildren();
|
||||
if (member === 'footing' && pier?.input.foundationType === '현장타설말뚝') {
|
||||
box.appendChild(el('p', '현장타설말뚝 · 기둥별 1본 · 직경은 기둥과 같음 · 확대기초 적용 안 함', 'muted small'));
|
||||
return;
|
||||
}
|
||||
SHAPE_GROUPS[member].forEach((group, gi) => {
|
||||
const row = el('div', undefined, 'shape-row');
|
||||
row.appendChild(el('span', group.title, 'lbl'));
|
||||
@@ -2529,6 +2559,8 @@ function placeViewer(stage: Stage): void {
|
||||
|
||||
/** The 철근표 group that a bar name belongs to. */
|
||||
function memberOfBar(name: string): RebarRow['member'] {
|
||||
if (name.startsWith('말뚝-')) return 'pile';
|
||||
if (/^(기둥|접합)/.test(name)) return 'column';
|
||||
if (name === 'S1' || name === 'S2') return 'bearing';
|
||||
if (name.startsWith('B')) return 'footing';
|
||||
if (name.startsWith('P') || name.includes('커플러')) return 'column';
|
||||
@@ -2592,7 +2624,7 @@ function setStage(stage: Stage): void {
|
||||
// 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.
|
||||
const selectionOnly = (s: Stage) => s === 'plan' || s === 'rebar';
|
||||
if ((allPiers !== null) !== multiSceneOn() || selectionOnly(stage) !== selectionOnly(previous)) setupScene();
|
||||
if ((allPiers !== null) !== multiSceneOn() || selectionOnly(stage) !== selectionOnly(previous) || (stage === 'rebar') !== (previous === 'rebar')) setupScene();
|
||||
if (state.scene) setRebarView(state.scene, stage === 'rebar');
|
||||
for (const g of abutGroups) setRebarViewOn(g, stage === 'rebar');
|
||||
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
|
||||
@@ -2619,6 +2651,7 @@ const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
|
||||
{ id: 'coping', label: '코핑' },
|
||||
{ id: 'column', label: '기둥' },
|
||||
{ id: 'footing', label: '확대기초' },
|
||||
{ id: 'pile', label: '현장타설말뚝' },
|
||||
];
|
||||
|
||||
/** 3D colour basis buttons on ③ and their legend (spec §6.7). */
|
||||
@@ -2774,8 +2807,10 @@ function toggleRow<T extends string>(options: [T, string][], current: T, onPick:
|
||||
function renderCopingSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
if (!pier.model) return;
|
||||
const wrap = el('div', undefined, 'bearing-ref');
|
||||
const current: CopingRebarSettings = { ...DEFAULT_COPING_REBAR, ...pier.draft.copingRebar };
|
||||
wrap.appendChild(el('p', '코핑 배근 입력 (기본값 = 코핑철근.md · 적용을 눌러야 반영)', 'lbl'));
|
||||
const defaults = copingDefaults(pier.model.columns.length);
|
||||
const current: CopingRebarSettings = { ...defaults, ...pier.draft.copingRebar };
|
||||
wrap.appendChild(el('p', `코핑 배근 입력 (기본값 = ${pier.model.columns.length > 1 ? '1106 다주형 전용 삽도' : '코핑철근.md'} · 적용을 눌러야 반영)`, 'lbl'));
|
||||
if (pier.model.columns.length > 1) wrap.appendChild(el('p', '전용 삽도 규격 우선 · C5/C6·중복 번호와 cycle별 이음 위치는 확인 중입니다. 저장된 간격 입력은 기본값보다 우선합니다.', 'muted small'));
|
||||
const grid = el('div', undefined, 'edit-fields');
|
||||
const controls = new Map<keyof CopingRebarSettings, HTMLInputElement | HTMLSelectElement>();
|
||||
for (const f of COPING_REBAR_FIELDS) {
|
||||
@@ -2803,8 +2838,8 @@ function renderCopingSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
label.htmlFor = control.id;
|
||||
controls.set(f.key, control);
|
||||
const [lo, hi] = COPING_REBAR_RANGES[f.key];
|
||||
const changed = current[f.key] !== DEFAULT_COPING_REBAR[f.key] ? ' · 기본과 다름' : '';
|
||||
row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COPING_REBAR[f.key]}${changed}`, 'muted small'));
|
||||
const changed = current[f.key] !== defaults[f.key] ? ' · 기본과 다름' : '';
|
||||
row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${defaults[f.key]}${changed}`, 'muted small'));
|
||||
grid.appendChild(row);
|
||||
}
|
||||
wrap.appendChild(grid);
|
||||
@@ -2817,7 +2852,7 @@ function renderCopingSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
drawSplice();
|
||||
}));
|
||||
if (splice.mode === 'apply') {
|
||||
spliceBox.appendChild(toggleRow([['center', '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => {
|
||||
spliceBox.appendChild(toggleRow([['center', pier.model!.columns.length > 1 ? '코핑 중앙 (임시)' : '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => {
|
||||
splice = { ...splice, at: v };
|
||||
drawSplice();
|
||||
}));
|
||||
@@ -2863,7 +2898,145 @@ function applyColumnRebar(name: string, next: ColumnRebarSettings): void {
|
||||
regenerateOne(name);
|
||||
}
|
||||
|
||||
/** One editor, with independent stored segments and explicit shared-field edits. */
|
||||
function renderCastSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
const saved = castRebarDefaults(pier.draft, materialFor(state.material, state.fckByMember, 'column'));
|
||||
const wrap = el('section', undefined, 'bearing-ref');
|
||||
wrap.dataset.castPart = 'combined';
|
||||
wrap.append(el('h3', '기둥·말뚝 배근 · 현장타설'), el('p', '확대기초 미적용 · 전 기둥·말뚝에 적용합니다. 주철근·횡철근·커플러는 적용된 입력으로 생성합니다. 미확정 구간은 알림과 재료표에 표시하며, 말뚝 저부 P6는 검토안으로 수량에서 제외합니다.', 'pnote'));
|
||||
wrap.appendChild(el('p', '단부 길이 기준: 기둥은 코핑 Level 하단에서 아래로, 말뚝은 β EL−3D에서 위로 적용합니다. 말뚝 단부 길이를 비우면 점토층 하단 EL+D까지 자동 계산합니다. 경계 미입력·역전·범위 이탈 시 해당 단부는 미계산으로 남깁니다.', 'muted small'));
|
||||
if (pier.model) {
|
||||
const drawing = el('details', undefined, 'abut-group');
|
||||
drawing.appendChild(el('summary', '기둥·말뚝 배근 삽도 · 첫 기둥'));
|
||||
const figure = el('figure');
|
||||
figure.innerHTML = castRebarSvg(pier.model, castRebarLayout(pier.model, materialFor(state.material, state.fckByMember, 'column')));
|
||||
drawing.appendChild(figure); wrap.appendChild(drawing);
|
||||
}
|
||||
const shape = castShape(pier.draft);
|
||||
const jointEl = pier.model?.columns[0]?.bottom;
|
||||
wrap.appendChild(el('p', `직경 ${pier.model?.columnDiameterMm ?? pier.draft.columnDiameterMm ?? 2000} mm · 기둥 하단/말뚝 두부 EL ${jointEl?.toFixed(3) ?? '미생성'} · 말뚝 길이 ${pier.draft.pileLengthMm} mm · 점토층 하단 ${shape.clayBottomEl === undefined ? '미입력' : `EL ${shape.clayBottomEl.toFixed(3)}`}`, 'muted small'));
|
||||
const plan = el('button', '② 형상계획에서 형상·지질 편집', 'btn small'); plan.type = 'button';
|
||||
plan.addEventListener('click', () => { $<HTMLButtonElement>('nav-plan').click(); });
|
||||
wrap.appendChild(plan);
|
||||
const common = el('div', undefined, 'edit-fields');
|
||||
const advanced = el('details', undefined, 'abut-group');
|
||||
advanced.appendChild(el('summary', '상세 설정 · 강도·피복 기준'));
|
||||
const advancedGrid = el('div', undefined, 'edit-fields'); advanced.appendChild(advancedGrid);
|
||||
const exceptions = el('details', undefined, 'abut-group'); exceptions.id = 'cip-exceptions';
|
||||
const numericKeys = ['mainDiameterMm', 'mainCount', 'innerMainDiameterMm', 'innerMainCount', 'coverMm', 'mainGradeMpa', 'concreteStrengthMpa'] as const;
|
||||
const different = numericKeys.filter(key => saved.column?.[key] !== saved.pile?.[key]);
|
||||
const coverDiffers = saved.column?.coverReference !== saved.pile?.coverReference;
|
||||
exceptions.open = different.length > 0 || coverDiffers;
|
||||
exceptions.appendChild(el('summary', exceptions.open ? '구간별 값 다름 · 원래 값 보존' : '구간별 예외 설정'));
|
||||
exceptions.appendChild(el('p', '공통값을 바꾸면 해당 항목만 두 구간에 적용합니다. 다른 항목과 구간별 예외는 유지됩니다. 피복은 기준도 함께 확인하세요.', 'pnote'));
|
||||
const inputs = { column: new Map<keyof CastRebarPart, HTMLInputElement | HTMLSelectElement>(), pile: new Map<keyof CastRebarPart, HTMLInputElement | HTMLSelectElement>() };
|
||||
const numberRow = (parent: HTMLElement, id: string, labelText: string, value?: number): HTMLInputElement => {
|
||||
const row = el('div', undefined, 'edit-row');
|
||||
const input = el('input');
|
||||
input.id = id; input.type = 'number'; input.step = id.endsWith('Count') ? '1' : 'any';
|
||||
input.value = value === undefined ? '' : String(value);
|
||||
input.placeholder = '미입력';
|
||||
const label = el('label', labelText); label.htmlFor = input.id;
|
||||
row.append(label, input); parent.appendChild(row); return input;
|
||||
};
|
||||
const selectRow = (parent: HTMLElement, id: string, labelText: string, choices: [string, string][], value?: string) => {
|
||||
const row = el('div', undefined, 'edit-row');
|
||||
const label = el('label', labelText); label.htmlFor = id;
|
||||
const select = el('select'); select.id = id;
|
||||
for (const [v, text] of [['', '미입력'], ...choices]) { const option = el('option', text); option.value = v; select.appendChild(option); }
|
||||
select.value = value ?? ''; row.append(label, select); parent.appendChild(row); return select;
|
||||
};
|
||||
wrap.appendChild(el('h4', '공통 주철근'));
|
||||
wrap.appendChild(common);
|
||||
for (const key of numericKeys) {
|
||||
const field = CAST_PART_FIELDS.find(f => f.key === key)!;
|
||||
const differs = different.includes(key) || key === 'coverMm' && coverDiffers;
|
||||
const control = numberRow(['mainGradeMpa', 'concreteStrengthMpa'].includes(key) ? advancedGrid : common, `cip-common-${key}`, field.label, differs ? undefined : saved.column?.[key]);
|
||||
if (differs) control.placeholder = '구간별 값 다름';
|
||||
// Segment controls are populated below; only an actual common-field edit synchronises them.
|
||||
control.addEventListener('input', () => { for (const part of ['column', 'pile'] as const) inputs[part].get(key)!.value = control.value; });
|
||||
}
|
||||
const coverChoices: [string, string][] = [['main-center', '외측 주철근 중심'], ['clear', '최외측 철근 표면']];
|
||||
const cover = selectRow(advancedGrid, 'cip-common-coverReference', '공통 피복 기준', coverChoices, coverDiffers ? undefined : saved.column?.coverReference);
|
||||
if (coverDiffers) cover.options[0].textContent = '구간별 값 다름';
|
||||
cover.addEventListener('change', () => { for (const part of ['column', 'pile'] as const) inputs[part].get('coverReference')!.value = cover.value; });
|
||||
for (const part of ['column', 'pile'] as const) {
|
||||
const grid = el('div', undefined, 'edit-fields');
|
||||
exceptions.append(el('h4', part === 'column' ? '기둥 구간 예외' : '말뚝 구간 예외'), grid);
|
||||
for (const key of numericKeys) {
|
||||
const field = CAST_PART_FIELDS.find(f => f.key === key)!;
|
||||
inputs[part].set(key, numberRow(grid, `cip-${part}-${key}`, field.label, saved[part]?.[key]));
|
||||
}
|
||||
inputs[part].set('coverReference', selectRow(grid, `cip-${part}-coverReference`, '피복 기준', coverChoices, saved[part]?.coverReference));
|
||||
}
|
||||
wrap.append(exceptions, advanced, el('h4', '횡철근 구간표'));
|
||||
const tableWrap = el('div', undefined, 'pier-table-wrap');
|
||||
const table = el('table', undefined, 'grid-table compact');
|
||||
const head = el('thead'); const heading = el('tr');
|
||||
for (const label of ['구간', '형식', '직경 (mm)', '일반 간격 (mm)', '단부 길이 (mm)', '단부 간격 (mm)']) heading.appendChild(el('th', label));
|
||||
head.appendChild(heading); table.appendChild(head);
|
||||
const body = el('tbody');
|
||||
for (const part of ['column', 'pile'] as const) {
|
||||
const name = part === 'column' ? '기둥' : '말뚝';
|
||||
const row = el('tr'); row.appendChild(el('th', name));
|
||||
const tieCell = el('td');
|
||||
inputs[part].set('tieKind', selectRow(tieCell, `cip-${part}-tieKind`, `${name} 횡철근 형식`, [['hoop', '띠철근'], ['spiral', '나선철근']], saved[part]?.tieKind));
|
||||
row.appendChild(tieCell);
|
||||
for (const key of ['tieDiameterMm', 'tieSpacingMm', 'endZoneLengthMm', 'endZoneSpacingMm'] as const) {
|
||||
const cell = el('td');
|
||||
inputs[part].set(key, numberRow(cell, `cip-${part}-${key}`, `${name} ${CAST_PART_FIELDS.find(f => f.key === key)!.label}`, saved[part]?.[key]));
|
||||
row.appendChild(cell);
|
||||
}
|
||||
body.appendChild(row);
|
||||
inputs[part].set('tieGradeMpa', numberRow(advancedGrid, `cip-${part}-tieGradeMpa`, `${name} 횡철근 강도 fy (MPa)`, saved[part]?.tieGradeMpa));
|
||||
}
|
||||
table.appendChild(body); tableWrap.appendChild(table); wrap.appendChild(tableWrap);
|
||||
wrap.appendChild(el('h4', '연결·하단'));
|
||||
const connectionGrid = el('div', undefined, 'edit-fields');
|
||||
const connectionMode = selectRow(connectionGrid, 'cip-connection-mode', '기둥·말뚝 공통 연결 방식 (검토 입력)', [['continuous', '연속'], ['lap', '겹침'], ['coupler', '커플러']], saved.connection?.mode);
|
||||
for (const f of CAST_PART_FIELDS.filter(f => f.only)) {
|
||||
const part = f.only!;
|
||||
inputs[part].set(f.key, numberRow(connectionGrid, `cip-${part}-${f.key}`, f.label, saved[part]?.[f.key]));
|
||||
}
|
||||
const connectionInputs = new Map<'lapLengthMm' | 'couplerLengthMm', HTMLInputElement>();
|
||||
for (const [key, label] of [['lapLengthMm', '접합부 겹침 길이 (mm)'], ['couplerLengthMm', '접합부 커플러 길이 (mm)']] as const) {
|
||||
connectionInputs.set(key, numberRow(advancedGrid, `cip-connection-${key}`, label, saved.connection?.[key]));
|
||||
}
|
||||
const columnValues = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar };
|
||||
const sharedDetails = new Map<'ctcCoupler' | 'dzCp2' | 'ctcP6', HTMLInputElement>();
|
||||
for (const [key, label] of [['ctcCoupler', '주기 주철근 커플러 간격 (mm)'], ['dzCp2', '내측 커플러 상승 높이 (mm)'], ['ctcP6', '기둥 크로스타이 단 간격 (mm)']] as const) {
|
||||
sharedDetails.set(key, numberRow(advancedGrid, `cip-detail-${key}`, label, columnValues[key]));
|
||||
}
|
||||
wrap.appendChild(connectionGrid);
|
||||
const msg = el('p', undefined, 'muted small'); msg.setAttribute('role', 'status');
|
||||
const apply = el('button', '배근 적용 · 미확정 구간 별도 표시', 'btn small'); apply.type = 'button'; apply.id = 'cip-apply';
|
||||
apply.addEventListener('click', () => {
|
||||
if ([...wrap.querySelectorAll<HTMLInputElement>('input')].some(input => input.validity.badInput)) { msg.textContent = '숫자 입력을 확인하세요.'; return; }
|
||||
const next: CastInPlaceRebar = { ...saved, connection: {} };
|
||||
for (const part of ['column', 'pile'] as const) {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const [key, input] of inputs[part]) if (input.value.trim() !== '') values[key] = key === 'tieKind' || key === 'coverReference' ? input.value : Number(input.value);
|
||||
next[part] = values as CastRebarPart;
|
||||
}
|
||||
if (connectionMode.value) next.connection!.mode = connectionMode.value as NonNullable<CastInPlaceRebar['connection']>['mode'];
|
||||
for (const [key, input] of connectionInputs) if (input.value.trim() !== '') next.connection![key] = Number(input.value);
|
||||
const issues = castSettingsIssues(next, pier.draft.pileLengthMm, pier.draft.columnDiameterMm ?? 2000);
|
||||
if (issues.length) { msg.textContent = issues.join(' · '); return; }
|
||||
const nextColumn = { ...columnValues };
|
||||
for (const [key, input] of sharedDetails) {
|
||||
if (!input.value.trim()) { msg.textContent = '커플러·크로스타이 상세 값을 입력하세요.'; return; }
|
||||
nextColumn[key] = Number(input.value);
|
||||
}
|
||||
const columnIssues = columnSettingsIssues(nextColumn);
|
||||
if (columnIssues.length) { msg.textContent = columnIssues.map(i => i.message).join(' · '); return; }
|
||||
pier.draft = { ...pier.draft, castInPlaceRebar: next, columnRebar: nextColumn };
|
||||
regenerateOne(pier.input.name);
|
||||
});
|
||||
wrap.append(msg, apply); box.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderColumnSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
if (pier.input.foundationType === '현장타설말뚝') { renderCastSettings(box, pier); return; }
|
||||
if (!pier.model) return;
|
||||
const wrap = el('div', undefined, 'bearing-ref');
|
||||
const current: ColumnRebarSettings = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar };
|
||||
@@ -2970,6 +3143,10 @@ function applyFootingRebar(name: string, next: FootingRebarSettings): void {
|
||||
}
|
||||
|
||||
function renderFootingSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
if (pier.input.foundationType === '현장타설말뚝') {
|
||||
box.appendChild(el('p', '확대기초 적용 안 함 · 현장타설말뚝 전용 입력을 사용하세요.', 'pnote'));
|
||||
return;
|
||||
}
|
||||
if (!pier.model) return;
|
||||
const wrap = el('div', undefined, 'bearing-ref');
|
||||
const current: FootingRebarSettings = { ...DEFAULT_FOOTING_REBAR, ...pier.draft.footingRebar };
|
||||
@@ -3308,7 +3485,7 @@ function renderAbutRebar(rec: AbutRecord): void {
|
||||
issues.appendChild(li);
|
||||
}
|
||||
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
|
||||
$('step3-status').textContent = member.ready ? `${rec.name} ${member.label} 계산됨 · 경고 ${warnOf(member.id)}` : `${rec.name} ${member.label} 다음 단계`;
|
||||
setHeaderStatus('step3-status', member.ready ? `경고·${warnOf(member.id)}` : '미완료', member.ready ? `${rec.name} ${member.label} 계산됨 · 경고 ${warnOf(member.id)}` : `${rec.name} ${member.label} 다음 단계`);
|
||||
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
|
||||
}
|
||||
|
||||
@@ -4088,6 +4265,56 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Registered review items are scoped independently from computed warnings. */
|
||||
function renderReviewTasks(): void {
|
||||
const rec = state.abutments.find((a) => a.name === state.abutActive);
|
||||
const pier = activePier();
|
||||
const list = $('review-tasks');
|
||||
list.replaceChildren();
|
||||
if (!rec && !pier?.model) {
|
||||
$('review-summary').textContent = '구조물과 부재를 선택하면 검토 항목을 표시합니다.';
|
||||
return;
|
||||
}
|
||||
const member = rec ? state.abutRebarMember : state.rebarMember;
|
||||
const label = rec ? ABUT_REBAR_MEMBERS.find((m) => m.id === member)?.label : pier?.input.foundationType === '현장타설말뚝' && (member === 'column' || member === 'pile') ? '기둥·말뚝' : REBAR_MEMBERS.find((m) => m.id === member)?.label;
|
||||
const tasks: { code: string; title: string; detail: string; marks: string[]; decided?: boolean }[] = [];
|
||||
if (rec && member === 'blk') tasks.push({ code: 'Q-S4', title: 'S1–W2 배치 확인', detail: '경고 유지 상태입니다. 높이 층 통과 경고와 실제 철근 간섭을 구분하고 배치·정착을 확인하세요.', marks: ['S1'] });
|
||||
if (rec && member === 'wing') tasks.push(
|
||||
{ code: 'E19', title: '갈고리 방향·정착 확인', detail: 'WR12 안쪽 갈고리와 WR7·WR10의 실제 다리 길이를 상세도와 대조하세요. 콘크리트 안에 있다는 것만으로 정착 충족을 판단하지 않습니다.', marks: ['WR12', 'WR7-1', 'WR7-2', 'WR7-3', 'WR10'] },
|
||||
{ code: 'V-A2', title: 'WR3·WR13 정착 확인', detail: 'RB-AW02가 표시된 구간의 정착 길이를 상세도와 대조하세요.', marks: ['WR3', 'WR13'] },
|
||||
);
|
||||
if (!rec && member === 'footing') tasks.push(
|
||||
{ code: 'Q-B5', title: '다리 길이 확인', detail: 'B1·B3·B5·B7의 제안값과 적용 도면을 대조하세요.', marks: ['B1', 'B3', 'B5', 'B7'] },
|
||||
{ code: 'Q-B6', title: '이음 위치·길이 확인', detail: 'B2·B6 이음의 계산값과 적용 도면을 대조하세요.', marks: ['B2', 'B6'] },
|
||||
);
|
||||
if (!rec && pier?.input.foundationType === '현장타설말뚝' && (member === 'column' || member === 'pile')) {
|
||||
tasks.push({ code: 'CP-F1', title: '현장타설 미확정 상세 확인', detail: '주철근·횡철근·커플러는 입력에 따라 생성합니다. 단부 경계, 기둥 크로스타이 상세, 말뚝 저부 P6 등 미완료 항목은 계산 알림을 확인하세요.', marks: [] });
|
||||
} else if (!rec && member === 'column') tasks.push({ code: 'Q-C9', title: 'P4–B11 간섭 처리', detail: '기존 결정: 이 간섭은 무시합니다. 도면·기록을 검토할 때 결정 내용을 함께 확인하세요.', marks: ['P4'], decided: true });
|
||||
const pending = tasks.filter((task) => !task.decided).length;
|
||||
$('review-summary').textContent = `${rec?.name ?? pier!.input.name} · ${label} · 등록된 검토 미결 ${pending}건${tasks.some((task) => task.decided) ? ' · 기존 결정 1건' : ''} (2026-11-05 기준). 계산 알림과 별도입니다.`;
|
||||
if (!tasks.length) list.append(el('li', '이 부재에 등록된 미결 항목은 없습니다. 위의 계산 알림도 함께 확인하세요.'));
|
||||
for (const task of tasks) {
|
||||
const item = el('li');
|
||||
item.append(el('strong', `${task.code} · ${task.title} · ${task.decided ? '결정됨' : '확인 필요'}`), el('p', task.detail, 'small'));
|
||||
const button = el('button', '해당 철근 보기', 'btn small');
|
||||
button.type = 'button';
|
||||
if (!task.marks.length) { button.disabled = true; button.textContent = '배근 상세 확정 후 표시'; }
|
||||
button.addEventListener('click', () => {
|
||||
state.rebarSelection = new Set(task.marks);
|
||||
state.rebarIsolate = false;
|
||||
renderAll();
|
||||
const row = [...document.querySelectorAll<HTMLTableRowElement>('#rebar-table tbody tr')].find((tr) => task.marks.includes(tr.querySelector('.bar-no')?.textContent ?? tr.cells[2]?.textContent ?? ''));
|
||||
if (row) {
|
||||
row.tabIndex = -1;
|
||||
row.focus({ preventScroll: true });
|
||||
row.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
}
|
||||
});
|
||||
item.append(button);
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRebar(): void {
|
||||
const pier = activePier();
|
||||
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
|
||||
@@ -4102,10 +4329,14 @@ function renderRebar(): void {
|
||||
$('rebar-title').textContent = '철근표';
|
||||
const tabs = $<HTMLUListElement>('rebar-tabs');
|
||||
tabs.replaceChildren();
|
||||
for (const m of REBAR_MEMBERS) {
|
||||
const cast = pier?.input.foundationType === '현장타설말뚝';
|
||||
if ((cast && (state.rebarMember === 'footing' || state.rebarMember === 'pile')) || (!cast && state.rebarMember === 'pile')) state.rebarMember = 'column';
|
||||
const members = REBAR_MEMBERS.filter(m => cast ? m.id !== 'footing' && m.id !== 'pile' : m.id !== 'pile');
|
||||
for (const m of members) {
|
||||
const li = el('li');
|
||||
const btn = el('button', m.label, 'member');
|
||||
btn.type = 'button';
|
||||
if (m.id === 'column' && cast) btn.textContent = '기둥·말뚝';
|
||||
btn.setAttribute('aria-pressed', String(m.id === state.rebarMember));
|
||||
btn.addEventListener('click', () => {
|
||||
state.rebarMember = m.id;
|
||||
@@ -4124,14 +4355,14 @@ function renderRebar(): void {
|
||||
$('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.';
|
||||
return;
|
||||
}
|
||||
const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember);
|
||||
const { rows, warnings, incomplete } = rebarSummary(pier.model, state.material, state.fckByMember);
|
||||
$('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
|
||||
if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier);
|
||||
if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier);
|
||||
if (state.rebarMember === 'coping') renderCopingSettings(box, pier);
|
||||
if (state.rebarMember === 'column') renderColumnSettings(box, pier);
|
||||
if (state.rebarMember === 'footing') renderFootingSettings(box, pier);
|
||||
const shown = rows.filter((r) => r.member === state.rebarMember);
|
||||
const shown = rows.filter((r) => r.member === state.rebarMember || cast && state.rebarMember === 'column' && r.member === 'pile');
|
||||
box.appendChild(rebarSelectBar(shown.map((r) => r.name)));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
@@ -4166,10 +4397,10 @@ function renderRebar(): void {
|
||||
pick.appendChild(check);
|
||||
tr.append(
|
||||
pick,
|
||||
el('td', ROLE[r.name] ?? '—'),
|
||||
el('td', ROLE[r.name] ?? (r.name.includes('커플러') ? '연결' : /P[12]/.test(r.name) ? '주철근' : r.name.includes('크로스타이') ? '크로스타이' : r.diameterMm !== undefined ? '횡철근' : '—')),
|
||||
nameCell,
|
||||
el('td', r.size),
|
||||
el('td', /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'),
|
||||
el('td', r.name.includes('커플러') ? '—' : r.gradeMpa !== undefined ? `SD${r.gradeMpa}` : /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'),
|
||||
el('td', r.spacing),
|
||||
el('td', r.actual),
|
||||
el('td', r.position),
|
||||
@@ -4207,12 +4438,12 @@ function renderRebar(): void {
|
||||
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
|
||||
// Warning count on each member tab (시안: 부재 탭 배지).
|
||||
const counts = (member: RebarRow['member']) => warnings.filter((w) => w.target === member).length;
|
||||
for (const [i, m] of REBAR_MEMBERS.entries()) {
|
||||
for (const [i, m] of members.entries()) {
|
||||
const badge = tabs.children[i]?.querySelector('button');
|
||||
const n = counts(m.id);
|
||||
const n = counts(m.id) + (cast && m.id === 'column' ? counts('pile') : 0);
|
||||
if (badge && n > 0) badge.appendChild(el('span', String(n), 'badge warn'));
|
||||
}
|
||||
$('step3-status').textContent = `계산됨 · 경고 ${warnings.length}`;
|
||||
setHeaderStatus('step3-status', `${incomplete.length ? '일부·' : '경고·'}${warnings.length}`, `${incomplete.length ? '일부 계산 · ' + incomplete.join(' · ') : '계산됨'} · 경고 ${warnings.length}`);
|
||||
renderMaterial();
|
||||
// 전단철근 구간 표 (시안 ③ 코핑 탭): segments of C9 / C10 / C11 along the coping, in metres.
|
||||
if (state.rebarMember === 'coping') {
|
||||
@@ -4241,16 +4472,16 @@ function renderRebar(): void {
|
||||
box.appendChild(st);
|
||||
}
|
||||
// Table and 3D model point at each other: the focused bar is highlighted in both.
|
||||
renderQuantityLink(box, rows);
|
||||
renderQuantityLink(box, rows, incomplete);
|
||||
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
|
||||
const focused = box.querySelector('tr.hl');
|
||||
if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
/** ③ keeps only a link to the 철근재료표 screen ④ (승인 2026-10-23). */
|
||||
function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void {
|
||||
const q = buildQuantity(rows, state.material);
|
||||
const line = el('p', `철근재료표 · 합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
||||
function renderQuantityLink(box: HTMLElement, rows: RebarRow[], incomplete: readonly string[] = []): void {
|
||||
const q = buildQuantity(rows, state.material, incomplete);
|
||||
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
||||
const link = el('button', '④ 재료표에서 보기', 'btn small');
|
||||
link.type = 'button';
|
||||
link.addEventListener('click', () => setStage('quantity'));
|
||||
@@ -4305,27 +4536,27 @@ function renderQuantityScreen(): void {
|
||||
allButton.textContent = `전체 합계 (${generated}기)`;
|
||||
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
||||
const targets = quantityTargets();
|
||||
$('step4-status').textContent = targets.length ? (state.quantityAll ? `${targets.length}기` : targets[0].input.name) : '예정';
|
||||
setHeaderStatus('step4-status', '', targets.length ? (state.quantityAll ? `${targets.length}기` : targets[0].input.name) : '예정');
|
||||
$('quantity-note').textContent = targets.length
|
||||
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
|
||||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||||
const entries = targets.flatMap((pier) =>
|
||||
pier.model ? [{ pier, q: buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material) }] : [],
|
||||
pier.model ? [{ pier, q: pierQuantity(pier) }] : [],
|
||||
);
|
||||
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
|
||||
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
|
||||
for (const { pier, q } of entries) {
|
||||
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
||||
if (q.missing.length) body.appendChild(el('p', `길이 미산정 ${q.missing.length}개는 합계에서 뺐습니다: ${q.missing.join(', ')}`, 'muted small'));
|
||||
if (q.missing.length) body.appendChild(el('p', `전체 수량 미완료 · 미계산 항목 ${q.missing.length}개 제외: ${q.missing.join(', ')}`, 'pnote'));
|
||||
body.appendChild(quantityTable(q));
|
||||
body.appendChild(el('p', `합계 ${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'));
|
||||
}
|
||||
}
|
||||
|
||||
/** 교량 전체 집계: the 강종·직경 totals over every pier on ④ (전체). */
|
||||
function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement {
|
||||
const wrap = el('section', undefined, 'quantity-total');
|
||||
wrap.appendChild(el('h3', `교량 전체 집계 · ${pierCount}기`));
|
||||
wrap.appendChild(el('h3', `교량 전체 ${total.missingCount ? '부분 집계 · 전체 수량 미완료' : '집계'} · ${pierCount}기`));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
const hr = el('tr');
|
||||
@@ -4342,23 +4573,29 @@ function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement
|
||||
table.appendChild(body);
|
||||
wrap.appendChild(table);
|
||||
const couplers = total.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음';
|
||||
const missing = total.missingCount ? ` · 길이 미산정 ${total.missingCount}개 제외` : '';
|
||||
const missing = total.missingCount ? ` · 미계산 항목 ${total.missingCount}개 제외 · 전체 수량 미완료` : '';
|
||||
wrap.appendChild(el('p', `합계 ${total.kg.toFixed(1)} kg · 할증 포함 ${total.kgWithWaste.toFixed(1)} kg (${total.ton.toFixed(3)} ton) · 커플러 ${couplers}${missing}`, 'pnote'));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Excel: one sheet per pier and a 합계 sheet (승인 2026-10-23). */
|
||||
function pierQuantity(pier: Pier): Quantity {
|
||||
if (!pier.model) throw new Error('형상이 생성되지 않았습니다.');
|
||||
const result = rebarSummary(pier.model, state.material, state.fckByMember);
|
||||
return buildQuantity(result.rows, state.material, result.incomplete);
|
||||
}
|
||||
|
||||
function exportQuantityWorkbook(): void {
|
||||
const targets = quantityTargets();
|
||||
if (!targets.length) return;
|
||||
const wb = XLSX.utils.book_new();
|
||||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton']];
|
||||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
|
||||
for (const pier of targets) {
|
||||
if (!pier.model) continue;
|
||||
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
|
||||
const q = pierQuantity(pier);
|
||||
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]);
|
||||
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`);
|
||||
@@ -4982,13 +5219,59 @@ function renderCopingInfo(box: HTMLDivElement, pier: Pier): void {
|
||||
box.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderCastShape(fields: HTMLDivElement, pier: Pier): void {
|
||||
const shape = castShape(pier.draft);
|
||||
const keys: { key: Exclude<keyof CastInPlaceShapeSettings, 'casingOn'>; label: string }[] = [
|
||||
{ key: 'betaLengthMm', label: '1/β 길이 (mm)' },
|
||||
{ key: 'weatheredRockEl', label: '풍화암 EL (m)' },
|
||||
{ key: 'bedrockEl', label: '기반암 EL (m)' },
|
||||
{ key: 'clayBottomEl', label: '점토층 하단 EL (m, 별도 입력)' },
|
||||
{ key: 'casingTopEl', label: '희생강관 상단 EL (m)' },
|
||||
{ key: 'casingBottomEl', label: '희생강관 하단 EL (m)' },
|
||||
];
|
||||
const change = (key: keyof CastInPlaceShapeSettings, value: number | boolean | undefined) => {
|
||||
const next = { ...pier.draft.castInPlaceShape, [key]: value };
|
||||
const issues = castShapeIssues(next);
|
||||
if (issues.length) { setError(issues.join(' · ')); return; }
|
||||
pier.draft = { ...pier.draft, castInPlaceShape: next };
|
||||
renderAll();
|
||||
};
|
||||
const label = el('label', undefined, 'edit-row');
|
||||
const enabled = el('input'); enabled.type = 'checkbox'; enabled.id = 'cip-casing-on'; enabled.checked = shape.casingOn;
|
||||
enabled.style.width = 'auto'; enabled.style.minHeight = 'auto';
|
||||
enabled.addEventListener('change', () => change('casingOn', enabled.checked));
|
||||
label.append(enabled, document.createTextNode('희생강관 적용')); fields.appendChild(label);
|
||||
for (const f of keys) {
|
||||
const row = el('div', undefined, 'edit-row');
|
||||
const lab = el('label', f.label); lab.htmlFor = `cip-shape-${f.key}`;
|
||||
const input = el('input'); input.id = lab.htmlFor; input.type = 'number'; input.step = 'any';
|
||||
input.value = shape[f.key] === undefined ? '' : String(shape[f.key]);
|
||||
input.placeholder = f.key === 'clayBottomEl' ? '직접 입력' : '자동 기본값';
|
||||
input.disabled = f.key.startsWith('casing') && !shape.casingOn;
|
||||
input.addEventListener('change', () => change(f.key, input.value.trim() ? Number(input.value) : undefined));
|
||||
const automatic = pier.draft.castInPlaceShape?.[f.key] === undefined;
|
||||
row.append(lab, input, el('span', f.key === 'clayBottomEl' && automatic ? '풍화암과 별도 · 입력 필요' : automatic ? '자동 기본값 · 비우면 자동 연동' : '직접 입력 · 비우면 자동값으로 복원', 'muted small'));
|
||||
fields.appendChild(row);
|
||||
}
|
||||
fields.appendChild(el('p', `말뚝 두부 = 지반고 EL ${shape.pileTopEl.toFixed(3)} · 말뚝 하단 EL ${shape.pileBottomEl.toFixed(3)} (지반고 − 말뚝길이). 풍화암 기본 = 선단+5 m, 기반암 기본 = 선단+1 m. 강관 기본 = 지반고+3 m ~ 기반암.`, 'muted small'));
|
||||
const zone = castLowerZone(pier.draft);
|
||||
fields.appendChild(el('p', zone ? `DXF 하단 단부구역 기준점: EL ${zone.bottomEl.toFixed(3)} ~ ${zone.topEl.toFixed(3)} (1/β−3D, 점토층 하단+D).${zone.valid ? '' : ' 구간 순서가 역전됩니다. 입력 EL을 확인하세요.'}` : '말뚝 하단 단부구역: 점토층 하단 EL 입력 필요. 풍화암 값으로 대체하지 않습니다.', 'muted small'));
|
||||
if (shape.casingOn) fields.appendChild(el('p', '희생강관은 현재 위치·범위 입력입니다. 강관 두께·재료 수량은 포함하지 않습니다.', 'muted small'));
|
||||
}
|
||||
|
||||
function renderPlan(): void {
|
||||
const pier = activePier();
|
||||
const cast = pier?.input.foundationType === '현장타설말뚝';
|
||||
const members = $<HTMLUListElement>('members');
|
||||
members.replaceChildren();
|
||||
for (const m of MEMBERS) {
|
||||
const li = el('li');
|
||||
const btn = el('button', m.label, 'member');
|
||||
if (m.id === 'footing' && cast) {
|
||||
const inactive = el('button', '확대기초 · 적용 안 함', 'member');
|
||||
inactive.disabled = true; inactive.title = '현장타설말뚝 형식에는 확대기초가 없습니다.';
|
||||
li.appendChild(inactive);
|
||||
}
|
||||
const btn = el('button', m.id === 'footing' && cast ? '현장타설말뚝' : m.label, 'member');
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-pressed', String(m.id === state.member));
|
||||
const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0;
|
||||
@@ -5002,7 +5285,7 @@ function renderPlan(): void {
|
||||
}
|
||||
|
||||
const member = MEMBERS.find((m) => m.id === state.member)!;
|
||||
$('member-title').textContent = member.label;
|
||||
$('member-title').textContent = member.id === 'footing' && cast ? '현장타설말뚝' : member.label;
|
||||
// Default and current selection are shown separately (spec §5.1). Only defaults are selectable today.
|
||||
// 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults.
|
||||
const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member.id === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : defaultSummary(member.id);
|
||||
@@ -5022,15 +5305,22 @@ function renderPlan(): void {
|
||||
regen.textContent = '다시 생성 · 변경 0';
|
||||
regen.disabled = true;
|
||||
} else {
|
||||
fieldRows(fields, pier, member.keys);
|
||||
if (member.id === 'footing') {
|
||||
fieldRows(fields, pier, member.id === 'footing' && cast ? ['groundEl', 'pileLengthMm'] : member.keys);
|
||||
if (member.id === 'footing' && cast) {
|
||||
$('member-summary').textContent = '현장타설말뚝 · 확대기초 적용 안 함';
|
||||
$('member-note').textContent = '말뚝 길이를 입력합니다. 두부는 기둥 하단에 연결하고 직경은 기둥 직경을 사용합니다.';
|
||||
const p = pier.model?.piles[0];
|
||||
fields.appendChild(el('p', `두부 EL ${p?.top.toFixed(3) ?? '미생성'} · 선단 EL ${p?.bottom.toFixed(3) ?? '미생성'} · 직경 ${pier.model?.columnDiameterMm ?? pier.draft.columnDiameterMm ?? 2000} mm`, 'muted small'));
|
||||
renderCastShape(fields, pier);
|
||||
}
|
||||
if (member.id === 'footing' && !cast) {
|
||||
renderPileSpec(fields, pier);
|
||||
renderFootingDrawings(fields, pier);
|
||||
}
|
||||
if (member.id === 'coping') renderCopingInfo(fields, pier);
|
||||
if (member.id === 'bearing') renderBearingRef(fields, pier);
|
||||
if (member.id === 'ground') renderGroundModel(fields, pier);
|
||||
if (FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember);
|
||||
if (!(member.id === 'footing' && cast) && FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember);
|
||||
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
|
||||
regen.disabled = pendingKeys(pier).length === 0;
|
||||
}
|
||||
@@ -5095,7 +5385,7 @@ function renderSaveStatus(): void {
|
||||
if (state.piers.length === 0) {
|
||||
status.textContent = '저장 전';
|
||||
} else if (state.savedSnapshot === null || isDirty()) {
|
||||
status.textContent = state.savedSnapshot === null ? '저장 전 · 변경 있음' : '변경 있음 · 미저장';
|
||||
status.textContent = '미저장';
|
||||
} else {
|
||||
status.textContent = '저장됨';
|
||||
}
|
||||
@@ -5269,6 +5559,36 @@ function selectable(p: Pending, e: Pier): boolean {
|
||||
return !!e.model && sheetOk(p.sheets, e.input.sheet);
|
||||
}
|
||||
|
||||
function selectableAbut(p: Pending, rec: AbutRecord): boolean {
|
||||
if (p.abutIssues.some((i) => i.level === 'E' && (i.pier === rec.name || (i.pier === undefined && i.sheet === rec.sheet)))) return false;
|
||||
try {
|
||||
buildAbut(toAbutInput(rec));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImportPreviews(p: Pending): void {
|
||||
renderPreview(p.entries.map((e) => ({ input: e.input, checked: e.checked && selectable(p, e) })));
|
||||
$('preview-title').textContent = `인식된 구조물 · 교각 ${p.entries.length}기 · 교대 ${p.abutments.length}기`;
|
||||
const table = $<HTMLTableElement>('pv-abuts');
|
||||
table.replaceChildren();
|
||||
if (!p.abutments.length) {
|
||||
table.createTBody().insertRow().append(el('td', '인식된 교대가 없습니다.'));
|
||||
return;
|
||||
}
|
||||
const head = table.createTHead().insertRow();
|
||||
for (const label of ['교대', '형식', '원본 시트', '교대축 (m)', '교좌면 EL (m)', '기초 하단 EL (m)', '받침', '불러오기']) head.append(el('th', label));
|
||||
const body = table.createTBody();
|
||||
for (const rec of p.abutments) {
|
||||
const tr = body.insertRow();
|
||||
const valid = selectableAbut(p, rec);
|
||||
const length = Math.hypot(rec.ip3.e - rec.ip1.e, rec.ip3.n - rec.ip1.n);
|
||||
for (const value of [rec.name, rec.type, rec.sheet, f3(length), f3(rec.seatEl), f3(rec.foundationBottomEl), String(rec.bearings.length), valid ? (p.selectedAbutments.has(rec.name) ? '예' : '아니오') : '입력 확인 필요']) tr.append(el('td', value));
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog(): void {
|
||||
const p = state.pending;
|
||||
const sheetsBox = $<HTMLUListElement>('sheets');
|
||||
@@ -5277,6 +5597,7 @@ function renderDialog(): void {
|
||||
if (!p) {
|
||||
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
|
||||
renderPreview([]);
|
||||
$('pv-abuts').replaceChildren();
|
||||
updateApply();
|
||||
return;
|
||||
}
|
||||
@@ -5301,11 +5622,13 @@ function renderDialog(): void {
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = entry.checked;
|
||||
check.dataset.importKind = 'pier';
|
||||
check.disabled = !selectable(p, entry);
|
||||
check.setAttribute('aria-label', `${entry.input.name} 불러오기`);
|
||||
check.addEventListener('change', () => {
|
||||
entry.checked = check.checked;
|
||||
updateApply();
|
||||
renderImportPreviews(p);
|
||||
});
|
||||
const name = el('span', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
|
||||
row.append(check, name);
|
||||
@@ -5313,7 +5636,41 @@ function renderDialog(): void {
|
||||
}
|
||||
sheetsBox.appendChild(li);
|
||||
}
|
||||
renderPreview(p ? p.entries.map((e) => ({ input: e.input, checked: e.checked })) : []);
|
||||
if (p.abutments.length || p.abutIssues.length) {
|
||||
const group = el('li');
|
||||
group.append(el('strong', `교대 인식 결과 · ${p.abutments.length}기`));
|
||||
for (const rec of p.abutments) {
|
||||
const valid = selectableAbut(p, rec);
|
||||
const row = el('div', undefined, 'sheet-pier');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.id = `import-abut-${rec.name}`;
|
||||
check.dataset.importKind = 'abut';
|
||||
check.checked = valid && p.selectedAbutments.has(rec.name);
|
||||
check.disabled = !valid;
|
||||
check.addEventListener('change', () => {
|
||||
if (check.checked) p.selectedAbutments.add(rec.name);
|
||||
else p.selectedAbutments.delete(rec.name);
|
||||
renderImportPreviews(p);
|
||||
updateApply();
|
||||
});
|
||||
const label = el('label', `${rec.name} · 교대 ${rec.type} · 시트 ${rec.sheet} · ${valid ? '선택 가능' : '입력 확인 필요'}`);
|
||||
label.htmlFor = check.id;
|
||||
row.append(check, label);
|
||||
group.append(row);
|
||||
}
|
||||
for (const item of groupAbutImportIssues(p.abutIssues)) {
|
||||
for (const issue of item.primary) group.append(el('p', issue.message, issue.level === 'E' ? 'bad small' : 'small'));
|
||||
if (item.related.length) {
|
||||
const details = el('details');
|
||||
details.append(el('summary', `${item.name || '교대'} · 관련 IP·받침 ${item.related.length}개 행 보기`, 'small'));
|
||||
for (const issue of item.related) details.append(el('p', issue.message, 'small'));
|
||||
group.append(details);
|
||||
}
|
||||
}
|
||||
sheetsBox.prepend(group);
|
||||
}
|
||||
renderImportPreviews(p);
|
||||
updateApply();
|
||||
}
|
||||
|
||||
@@ -5377,17 +5734,22 @@ function updateApply(): void {
|
||||
const p = state.pending;
|
||||
const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0;
|
||||
const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0;
|
||||
$('dlg-selected').textContent = `선택 ${count} / ${total}기`;
|
||||
const abuts = p ? p.abutments.filter((rec) => selectableAbut(p, rec)) : [];
|
||||
const selectedAbuts = p ? abuts.filter((rec) => p.selectedAbutments.has(rec.name)).length : 0;
|
||||
const scope = `교각 ${count}기 + 교대 ${selectedAbuts}기`;
|
||||
$('dlg-selected').textContent = p
|
||||
? `선택 ${scope} · 선택 가능: 교각 ${total}기, 교대 ${abuts.length}기${count === 0 && selectedAbuts > 0 ? ' · 교각도 1기 이상 선택하세요.' : ''}`
|
||||
: '파일을 선택하면 불러올 교각·교대를 확인할 수 있습니다.';
|
||||
const named = $<HTMLInputElement>('project-name').value.trim().length > 0;
|
||||
const apply = $<HTMLButtonElement>('dlg-apply');
|
||||
apply.textContent = `선택한 교각 ${count}기 형상 생성`;
|
||||
apply.textContent = count || selectedAbuts ? `${scope} 가져오기` : '불러올 구조물을 선택하세요';
|
||||
apply.disabled = count === 0 || !named;
|
||||
}
|
||||
|
||||
function loadBytes(bytes: Uint8Array, fileName: string): void {
|
||||
const wb = XLSX.read(bytes, { type: 'array' });
|
||||
const { piers, sheets } = parseWorkbook(wb);
|
||||
const abut = parseAbutments(wb);
|
||||
const abut = parseAbutments(wb, piers.map((pier) => pier.name));
|
||||
const entries = piers.map((input) => buildEntry(input, false));
|
||||
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
|
||||
for (const sheet of sheets) {
|
||||
@@ -5395,20 +5757,41 @@ function loadBytes(bytes: Uint8Array, fileName: string): void {
|
||||
for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E'));
|
||||
sheet.ok = sheet.ok && own.every((e) => e.model !== null);
|
||||
}
|
||||
const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues };
|
||||
const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues, selectedAbutments: new Set() };
|
||||
for (const e of entries) e.checked = selectable(pending, e);
|
||||
for (const rec of pending.abutments) if (selectableAbut(pending, rec)) pending.selectedAbutments.add(rec.name);
|
||||
state.pending = pending;
|
||||
renderDialog();
|
||||
}
|
||||
|
||||
let workbookReadVersion = 0;
|
||||
async function readWorkbook(fileName: string, bytes: () => Promise<Uint8Array>): Promise<boolean> {
|
||||
const version = ++workbookReadVersion;
|
||||
state.pending = null;
|
||||
renderDialog();
|
||||
$('file-name').textContent = `파일을 읽고 있습니다: ${fileName}`;
|
||||
try {
|
||||
const data = await bytes();
|
||||
if (version !== workbookReadVersion) return false;
|
||||
loadBytes(data, fileName);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (version === workbookReadVersion) {
|
||||
$('file-name').textContent = `${fileName}을 읽지 못했습니다. 파일을 다시 선택하세요.`;
|
||||
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyImport(): void {
|
||||
const p = state.pending;
|
||||
const name = $<HTMLInputElement>('project-name').value.trim();
|
||||
if (!p || !name) return;
|
||||
if (!p || !name || !p.entries.some((e) => e.checked && selectable(p, e))) return;
|
||||
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
|
||||
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
|
||||
state.abutments = p.abutments;
|
||||
state.abutDefaults = JSON.parse(JSON.stringify(p.abutments)) as AbutRecord[];
|
||||
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.active = state.piers[0]?.input.name ?? null;
|
||||
state.filter = 'all';
|
||||
state.pending = null;
|
||||
@@ -5558,13 +5941,11 @@ $<HTMLButtonElement>('download-template').addEventListener('click', () => {
|
||||
XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
|
||||
});
|
||||
$<HTMLInputElement>('file').addEventListener('change', async (ev) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||||
} catch (err) {
|
||||
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
||||
}
|
||||
input.value = '';
|
||||
await readWorkbook(file.name, async () => new Uint8Array(await file.arrayBuffer()));
|
||||
});
|
||||
/** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */
|
||||
const SAMPLE_PROJECT = { name: '운곡교P1', bridge: '운곡교' } as const;
|
||||
@@ -5577,12 +5958,7 @@ function fillSampleNames(): void {
|
||||
}
|
||||
|
||||
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
|
||||
try {
|
||||
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
|
||||
fillSampleNames();
|
||||
} catch (err) {
|
||||
setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
|
||||
}
|
||||
if (await readWorkbook('샘플 P1 (운곡교P1입력.xlsx)', fetchSample)) fillSampleNames();
|
||||
});
|
||||
|
||||
const drop = $<HTMLDivElement>('drop');
|
||||
@@ -5596,11 +5972,7 @@ drop.addEventListener('drop', async (ev) => {
|
||||
drop.classList.remove('over');
|
||||
const file = ev.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||||
} catch (err) {
|
||||
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
||||
}
|
||||
await readWorkbook(file.name, async () => new Uint8Array(await file.arrayBuffer()));
|
||||
});
|
||||
|
||||
$<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
|
||||
@@ -5610,6 +5982,20 @@ $<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
|
||||
|
||||
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
|
||||
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
|
||||
const fileMenu = $<HTMLDetailsElement>('file-menu');
|
||||
fileMenu.addEventListener('click', ev => {
|
||||
if ((ev.target as HTMLElement).closest('button')) fileMenu.open = false;
|
||||
});
|
||||
document.addEventListener('click', ev => {
|
||||
if (!fileMenu.contains(ev.target as Node)) fileMenu.open = false;
|
||||
});
|
||||
fileMenu.addEventListener('keydown', ev => {
|
||||
if (ev.key === 'Escape' && fileMenu.open) {
|
||||
fileMenu.open = false;
|
||||
fileMenu.querySelector('summary')!.focus();
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
$<HTMLInputElement>('project-file').addEventListener('change', (ev) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
if (file) void openProjectFile(file);
|
||||
@@ -5702,6 +6088,52 @@ function restorePanel(id: string): void {
|
||||
restorePanel('parts-info');
|
||||
restorePanel('color-info');
|
||||
|
||||
/** Viewer sizing is a local display preference, separate from project/design data. */
|
||||
function initReviewWorkspace(): void {
|
||||
const workspace = $('review-workspace');
|
||||
const slider = $<HTMLInputElement>('review-model-height');
|
||||
const toggle = $<HTMLButtonElement>('review-model-toggle');
|
||||
let collapsed = false;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null');
|
||||
if (saved && Number.isFinite(saved.height) && saved.height >= 180 && saved.height <= 560) slider.value = String(saved.height);
|
||||
collapsed = saved?.collapsed === true;
|
||||
} catch { /* A blocked store must not prevent resizing. */ }
|
||||
const apply = () => {
|
||||
workspace.style.setProperty('--review-model-height', `${slider.value}px`);
|
||||
workspace.classList.toggle('model-collapsed', collapsed);
|
||||
$('review-model-height-value').textContent = `${slider.value} px`;
|
||||
toggle.textContent = collapsed ? '모델 펼치기' : '모델 접기';
|
||||
toggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
slider.disabled = collapsed;
|
||||
try { localStorage.setItem('pierzainer.review-layout', JSON.stringify({ height: Number(slider.value), collapsed })); } catch { /* Display preference only. */ }
|
||||
};
|
||||
slider.addEventListener('input', apply);
|
||||
toggle.addEventListener('click', () => { collapsed = !collapsed; apply(); });
|
||||
const jump = (selector: string) => {
|
||||
const node = document.querySelector<HTMLElement>(selector);
|
||||
if (!node) return;
|
||||
if (!node.matches('input, select, button, a[href]')) node.tabIndex = -1;
|
||||
node.focus({ preventScroll: true });
|
||||
node.scrollIntoView({ block: 'start', inline: 'nearest' });
|
||||
};
|
||||
$('review-inputs').addEventListener('click', () => jump('#rebar-table .abut-rebar-inputs input, #rebar-table input[type="number"], #rebar-table select'));
|
||||
$('review-table').addEventListener('click', () => jump('#rebar-table table.rebar-table'));
|
||||
apply();
|
||||
new ResizeObserver(() => {
|
||||
if (workspace.clientHeight === 0 || !window.matchMedia('(min-width: 1101px)').matches) return;
|
||||
const available = workspace.clientHeight - workspace.querySelector<HTMLElement>('.workspace-tools')!.offsetHeight - 264;
|
||||
const max = Math.max(180, Math.min(560, Math.floor(available / 20) * 20));
|
||||
const next = Math.min(Number(slider.value), max);
|
||||
if (slider.max !== String(max) || slider.value !== String(next)) {
|
||||
slider.max = String(max);
|
||||
slider.value = String(next);
|
||||
apply();
|
||||
}
|
||||
}).observe(workspace);
|
||||
}
|
||||
initReviewWorkspace();
|
||||
|
||||
$<HTMLButtonElement>('all-toggle').addEventListener('click', () => {
|
||||
state.allView = !state.allView;
|
||||
setupScene();
|
||||
|
||||
@@ -534,6 +534,21 @@ summary {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sheet-pier label { flex: 1; cursor: pointer; }
|
||||
.sheet-pier input:disabled + label { color: var(--muted); cursor: default; }
|
||||
|
||||
.material-fields { border: 0; padding: 0; margin: 16px 0 0; min-width: 0; }
|
||||
.material-fields legend { color: var(--muted); font-weight: 600; padding: 0; margin-bottom: 8px; }
|
||||
.material-fields .mat { display: grid; grid-template-columns: minmax(0, 1fr) minmax(88px, 1fr); align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.material-fields input, .material-fields select { width: 100%; min-width: 0; }
|
||||
.review-tasks { display: grid; gap: 12px; }
|
||||
.review-tasks li > p { margin: 4px 0 8px; }
|
||||
.workspace-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.model-height-control { display: flex; align-items: center; gap: 8px; font-size: 12px; flex: 1 1 240px; }
|
||||
.model-height-control input { width: 100%; min-width: 72px; flex: 1; accent-color: var(--primary); }
|
||||
.model-height-control output { min-width: 48px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.workspace-tools button { flex: 0 0 auto; }
|
||||
|
||||
.rules {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
@@ -752,6 +767,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
|
||||
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
|
||||
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
|
||||
.review-workspace { grid-template-rows: auto minmax(180px, var(--review-model-height, 240px)) minmax(0, 1fr); }
|
||||
.review-workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
|
||||
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
|
||||
@@ -765,9 +782,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.grid > .panel, .grid > .center-col { max-height: 60vh; overflow: auto; }
|
||||
.center-col { display: flex; flex-direction: column; gap: 16px; }
|
||||
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
|
||||
.grid > .review-workspace { max-height: none; }
|
||||
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
|
||||
.review-workspace > .viewer { max-height: 75vh; overflow: auto; }
|
||||
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
|
||||
}
|
||||
|
||||
.review-workspace.model-collapsed #slot-rebar { display: none; }
|
||||
|
||||
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
|
||||
#viewer-block { position: relative; }
|
||||
#viewer-block .canvas { position: relative; }
|
||||
@@ -784,6 +806,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.viewer-stack { display: flex; flex-direction: column; gap: 8px; width: 168px; z-index: 5; pointer-events: none; }
|
||||
.viewer-stack > * { pointer-events: auto; }
|
||||
#viewer-block .viewer-stack-right { position: absolute; top: 8px; right: 8px; width: 180px; align-items: stretch; }
|
||||
.review-workspace #viewer-block .viewer-stack-right { bottom: 8px; max-height: calc(100% - 16px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
|
||||
.review-workspace #all-toggle { display: none; }
|
||||
#viewer-block .info-panel { background: rgba(255, 255, 255, 0.92); border: 1px solid #d5dde6; border-radius: 6px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); padding: 0 8px 8px; font-size: 13px; }
|
||||
#viewer-block .info-panel > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 700; color: #18212b; list-style-position: outside; }
|
||||
#viewer-block .ground-map-options { display: flex; flex-direction: column; gap: 4px; }
|
||||
@@ -964,3 +988,24 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
#rebar-table .abut-rebar-tablehead { display: flex; justify-content: space-between; align-items: center; margin: 8px 0 4px; }
|
||||
#rebar-table .abut-grp { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
|
||||
#rebar-table .abut-opts { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
/* Compact desktop header; the file controls retain their native button behaviour. */
|
||||
.top { flex-wrap: nowrap; }
|
||||
.top .brand { min-width: 0; flex: 1; }
|
||||
.top .brand strong { flex-shrink: 0; }
|
||||
.top .proj { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top .steps, .top .actions { flex-wrap: nowrap; flex-shrink: 0; align-items: center; white-space: nowrap; }
|
||||
.top .step em:empty { display: none; }
|
||||
.file-menu { position: relative; margin: 0; }
|
||||
.file-menu > summary { cursor: pointer; list-style: none; gap: 8px; }
|
||||
.file-menu > summary::after { content: '▾'; }
|
||||
.file-menu-items { position: absolute; right: 0; top: 100%; z-index: 20; display: flex; flex-direction: column; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.file-menu-items .btn { white-space: nowrap; color: var(--text); }
|
||||
[data-cast-part='combined'] table .edit-row { display: block; }
|
||||
[data-cast-part='combined'] table label { display: block; min-width: 0; font-size: 12px; }
|
||||
[data-cast-part='combined'] table input { width: 110px; }
|
||||
@media (max-width: 1100px) {
|
||||
.top { flex-wrap: wrap; }
|
||||
.top .brand { flex-basis: 100%; }
|
||||
.top .steps, .top .actions { flex-wrap: wrap; flex-shrink: 1; }
|
||||
}
|
||||
Reference in new issue
Block a user