diff --git a/scripts/verify-abut-only.cjs b/scripts/verify-abut-only.cjs new file mode 100644 index 0000000..c650fae --- /dev/null +++ b/scripts/verify-abut-only.cjs @@ -0,0 +1,76 @@ +// 교대만 가져오기 (2026-11-06): CDP 9444 Chrome + Vite 5173. Imports the sample workbook with P1 unchecked and A1 only, +// then walks ① ② ③, and checks for runtime exceptions; screenshots go to .verify/abut-only-20261106. +const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); +const out = path.resolve(__dirname, '../.verify/abut-only-20261106'); +fs.mkdirSync(out, { recursive: true }); +const report = { checks: [], errors: [], console: [] }; +const delay = ms => new Promise(r => setTimeout(r, ms)); +(async () => { + const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json(); + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); + let seq = 0; const pending = new Map(); + ws.onmessage = ({ data }) => { + const m = JSON.parse(data), p = pending.get(m.id); + if (p) { 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 === 'Runtime.consoleAPICalled' && m.params.type === 'error') report.console.push(m.params.args.map(a => a.value ?? a.description).join(' ')); + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); 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 expr => { for (let i = 0; i < 100; i++) { if (await ev(expr)) return; await delay(150); } throw new Error(`Wait failed: ${expr}`); }; + const click = expr => ev(`(() => { const e = ${expr}; if (!e || e.disabled) throw new Error('Missing/disabled: ' + ${JSON.stringify(expr)}); e.click(); })()`); + const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); }; + const shot = async name => { await delay(400); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); }; + try { + await send('Page.enable'); await send('Runtime.enable'); + await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); + await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); + await until(`typeof window.loadBytesToApp === 'function'`); + await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`); + await until(`!!document.getElementById('import-abut-A1')`); + // Uncheck every pier, keep A1. + await ev(`(() => { for (const c of document.querySelectorAll('input[data-import-kind="pier"]')) if (c.checked) { c.checked = false; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`); + await ev(`(() => { const c = document.getElementById('import-abut-A1'); if (!c.checked) { c.checked = true; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`); + await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교대만 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`); + const label = await ev(`document.getElementById('dlg-apply').textContent`); + check(`apply button allows A1 only: "${label}"`, !(await ev(`document.getElementById('dlg-apply').disabled`)) && label.includes('교각 0기 + 교대 1기')); + check('dialog note says abutments only', (await ev(`document.getElementById('dlg-selected').textContent`)).includes('교대만 가져옵니다')); + await click(`document.getElementById('dlg-apply')`); + await until(`!document.querySelector('.app').hidden`); + await shot('1-shape'); + check('① list shows A1', (await ev(`document.body.innerText`)).includes('A1 · A1 교대')); + // WebGL canvases cannot be read back without preserveDrawingBuffer: the 3D view is judged on the screenshots. + for (const [nav, name] of [['nav-plan', '2-plan'], ['nav-rebar', '3-rebar'], ['nav-shape', '4-shape-again']]) { + await click(`document.getElementById('${nav}')`); + await shot(name); + // Pick the abutment in the visible list of this stage, when there is one. + await ev(`(() => { const b = [...document.querySelectorAll('main:not([hidden]) button')].find(x => x.textContent.startsWith('A1 ·')); if (b) b.click(); })()`); + await shot(name + '-A1'); + } + check('no runtime exceptions (교대만)', report.errors.length === 0); + // Regression: P1 + A1 together still import, and P1 front-row solplates pair with A1. + await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); + await until(`typeof window.loadBytesToApp === 'function'`); + await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`); + await until(`!!document.getElementById('import-abut-A1')`); + await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교각+교대 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`); + check('P1 + A1 apply label', (await ev(`document.getElementById('dlg-apply').textContent`)).includes('교각 1기 + 교대 1기')); + await click(`document.getElementById('dlg-apply')`); + await until(`!document.querySelector('.app').hidden`); + await click(`document.getElementById('nav-plan')`); + await shot('5-pier-plan'); + check('no runtime exceptions (교각 + 교대)', report.errors.length === 0); + } catch (err) { + report.failure = String(err && err.stack || err); + console.error(report.failure); + } finally { + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ checks: report.checks.length, errors: report.errors, console: report.console.slice(0, 5), failure: report.failure }, null, 2)); + ws.close(); + } +})(); diff --git a/src/main.ts b/src/main.ts index a0280b8..d25850c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -652,12 +652,14 @@ function setupScene(): void { abutGroupByName.clear(); // The frame is the same one the piers use: the first shown pier in the all-piers view, otherwise the active pier. const ref = multiSceneOn() ? shownPiers().find((q) => q.model)?.model : (activePier()?.model ?? state.piers.find((q) => q.model)?.model); - if (ref) { + // 교대만 불러온 경우 (2026-11-06): no pier frame, so the first abutment's IP1 is the frame origin. + const pivot = ref?.pivot ?? abutOnlyPivot(); + if (pivot) { for (const rec of state.abutments) { try { const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); - const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab); + const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab); group.visible = abutShown(state.stage, state.abutActive, rec.name); // The 부재 표시 toggles hold for a new abutment group too. for (const [name, on] of Object.entries(state.abutVisible)) { @@ -730,6 +732,17 @@ function setupScene(): void { rebar3d.setColorMode(state.rebarColor); world.add(rebar3d.group); } + // 교대만 있는 프로젝트: no pier scene framed the camera above, so fit the abutments (setView → abutView). + if (!state.scene && !allPiers && state.piers.length === 0 && abutGroups.length) setView(state.view); +} + +/** Frame origin when no pier is loaded (교대만 불러오기, 2026-11-06): IP1 of the first abutment that builds. */ +function abutOnlyPivot(): { e: number; n: number } | undefined { + if (state.piers.length) return undefined; + for (const rec of state.abutments) { + try { return buildAbut(toAbutInput(rec)).ip1; } catch { /* 다음 교대 */ } + } + return undefined; } // --- main list (시안 ① 교각 목록) ---------------------------------------------------- @@ -5803,12 +5816,13 @@ function updateApply(): void { 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기 이상 선택하세요.' : ''}` + ? `선택 ${scope} · 선택 가능: 교각 ${total}기, 교대 ${abuts.length}기${count === 0 && selectedAbuts > 0 ? ' · 교대만 가져옵니다.' : ''}` : '파일을 선택하면 불러올 교각·교대를 확인할 수 있습니다.'; const named = $('project-name').value.trim().length > 0; const apply = $('dlg-apply'); apply.textContent = count || selectedAbuts ? `${scope} 가져오기` : '불러올 구조물을 선택하세요'; - apply.disabled = count === 0 || !named; + // 교대만 가져오기도 허용한다 (2026-11-06): 교각 또는 교대가 1기 이상이면 된다. + apply.disabled = (count === 0 && selectedAbuts === 0) || !named; } function loadBytes(bytes: Uint8Array, fileName: string): void { @@ -5854,7 +5868,9 @@ async function readWorkbook(fileName: string, bytes: () => Promise): function applyImport(): void { const p = state.pending; const name = $('project-name').value.trim(); - if (!p || !name || !p.entries.some((e) => e.checked && selectable(p, e))) return; + const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e)); + const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec)); + if (!p || !name || (!anyPier && !anyAbut)) 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.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec)); diff --git a/tests/bearing-pair.test.ts b/tests/bearing-pair.test.ts index f2f92dc..51a2231 100644 --- a/tests/bearing-pair.test.ts +++ b/tests/bearing-pair.test.ts @@ -113,3 +113,14 @@ describe('짝 받침 성립 범위 (검토 2026-11-06)', () => { expect(pairs.every((p) => p.warnings.length === 0)).toBe(true); }); }); + +describe('교대만 있는 프로젝트 (가져오기 허용 2026-11-06)', () => { + it('a project file with no pier and A1 only round-trips', async () => { + const { parseProject, serializeProject, SCHEMA_VERSION } = await import('../src/core/project'); + const file = { schemaVersion: SCHEMA_VERSION, project: { name: '교대만', bridge: '', client: '', author: '', crs: '', memo: '' }, + source: { fileName: '운곡교P1입력.xlsx', sheets: [] }, history: [], piers: [], abutments: [a1] }; + const back = parseProject(serializeProject(file as Parameters[0])); + expect(back.piers).toEqual([]); + expect(back.abutments?.map((a) => a.name)).toEqual(['A1']); + }); +});