From da7b13a568df23eff1f67c27be0e36456dc647e8 Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 6 Nov 2026 13:15:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EA=B8=B0=EB=91=A5=C2=B7=EB=A7=90?= =?UTF-8?q?=EB=9A=9D=20=ED=86=B5=ED=95=A9=20=EC=9E=85=EB=A0=A5=20=C2=B7=20?= =?UTF-8?q?=ED=98=84=EC=9E=A5=ED=83=80=EC=84=A4=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=ED=97=A4=EB=8D=94=20=ED=95=9C=20=EC=A4=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기둥·말뚝 입력을 한 카드로 통합(공통 입력, 구간별 예외 보존, 횡철근 표) - 현장타설 실배근 · 삽도 · 수량을 화면에 연결하고 프로젝트 저장 · 복원 - 상단 헤더를 한 줄로 축약(상태 축약, 파일 메뉴, 말줄임) - 디자인 검토 반영(가져오기 범위, ③ 모델 높이 · 접기, 검토 항목 이동) - 화면 검증 스크립트(verify-unified-input · verify-design-fixes 등) 보관 Co-Authored-By: Claude Haiku 5.5 --- README.md | 31 +- index.html | 63 ++- scripts/inspect-design-system-artifact.cjs | 52 ++ scripts/inspect-unified-ui.cjs | 21 + scripts/review-design-a.cjs | 49 ++ scripts/review-design-b.cjs | 52 ++ scripts/verify-design-fixes.cjs | 130 +++++ scripts/verify-unified-input.cjs | 149 ++++++ src/main.ts | 570 ++++++++++++++++++--- src/style.css | 45 ++ 10 files changed, 1067 insertions(+), 95 deletions(-) create mode 100644 scripts/inspect-design-system-artifact.cjs create mode 100644 scripts/inspect-unified-ui.cjs create mode 100644 scripts/review-design-a.cjs create mode 100644 scripts/review-design-b.cjs create mode 100644 scripts/verify-design-fixes.cjs create mode 100644 scripts/verify-unified-input.cjs diff --git a/README.md b/README.md index 703373a..774f556 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +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와 브랜치 전환은 수행하지 않았습니다. diff --git a/index.html b/index.html index 409d27f..be1c69a 100644 --- a/index.html +++ b/index.html @@ -11,22 +11,26 @@
Pierzainer - 교각 설계 프로젝트 미지정
저장 전 - + - - - +
+ 불러오기 +
+ + +
+
+
@@ -288,7 +292,16 @@

    형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.

    -
    +
    +
    + + + + +

    철근표

    @@ -299,24 +312,26 @@ @@ -389,20 +404,22 @@

    미리보기

    -

    +

    교각 정보

    +

    교대 정보

    +

    받침

    - +
    교각번호x (m)y (m)z (m)방위각위치
    구조물번호x (m)y (m)z (m)방위각위치

    IP

    - +
    교각점x (m)y (m)꺾임각
    구조물점x (m)y (m)꺾임각
    @@ -411,9 +428,9 @@
    - + - +
    diff --git a/scripts/inspect-design-system-artifact.cjs b/scripts/inspect-design-system-artifact.cjs new file mode 100644 index 0000000..5f32802 --- /dev/null +++ b/scripts/inspect-design-system-artifact.cjs @@ -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; }); diff --git a/scripts/inspect-unified-ui.cjs b/scripts/inspect-unified-ui.cjs new file mode 100644 index 0000000..55978f5 --- /dev/null +++ b/scripts/inspect-unified-ui.cjs @@ -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;}); diff --git a/scripts/review-design-a.cjs b/scripts/review-design-a.cjs new file mode 100644 index 0000000..673c9a6 --- /dev/null +++ b/scripts/review-design-a.cjs @@ -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}); diff --git a/scripts/review-design-b.cjs b/scripts/review-design-b.cjs new file mode 100644 index 0000000..bbc70f2 --- /dev/null +++ b/scripts/review-design-b.cjs @@ -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();}); diff --git a/scripts/verify-design-fixes.cjs b/scripts/verify-design-fixes.cjs new file mode 100644 index 0000000..56caa68 --- /dev/null +++ b/scripts/verify-design-fixes.cjs @@ -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(); +}); diff --git a/scripts/verify-unified-input.cjs b/scripts/verify-unified-input.cjs new file mode 100644 index 0000000..16065bd --- /dev/null +++ b/scripts/verify-unified-input.cjs @@ -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(); +}); diff --git a/src/main.ts b/src/main.ts index 5c2cc4f..73a63ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; } 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 = $('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 = $('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'); $('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(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(); 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', () => { $('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(), pile: new Map() }; + 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('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 = {}; + 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['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('#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 = $('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; 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 = $('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 = $('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 = $('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 = $('project-name').value.trim().length > 0; const apply = $('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): Promise { + 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 = $('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: $('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 @@ $('download-template').addEventListener('click', () => { XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx'); }); $('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 { } $('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 = $('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())); }); $('type-filter').addEventListener('change', (ev) => { @@ -5610,6 +5982,20 @@ $('type-filter').addEventListener('change', (ev) => { $('save-project').addEventListener('click', saveProject); $('open-project').addEventListener('click', () => $('project-file').click()); +const fileMenu = $('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(); + } +}); $('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 = $('review-model-height'); + const toggle = $('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(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('.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(); + $('all-toggle').addEventListener('click', () => { state.allView = !state.allView; setupScene(); diff --git a/src/style.css b/src/style.css index 56d3614..b393267 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } +}