diff --git a/scripts/diagnose-cast-in-place.mjs b/scripts/diagnose-cast-in-place.mjs new file mode 100644 index 0000000..396e014 --- /dev/null +++ b/scripts/diagnose-cast-in-place.mjs @@ -0,0 +1,26 @@ +// Read-only diagnosis: concrete scene vs rebar paths for every pier/foundation combination. +import { createServer } from 'vite'; +import { mkdir, writeFile } from 'node:fs/promises'; +const server = await createServer({ configFile: false, server: { middlewareMode: true, hmr: false, watch: null }, appType: 'custom' }); +try { + const { loadP1 } = await server.ssrLoadModule('/tests/helpers.ts'); + const { buildPier } = await server.ssrLoadModule('/src/core/geometry.ts'); + const { buildPierScene } = await server.ssrLoadModule('/src/view/scene.ts'); + const { buildRebar3D } = await server.ssrLoadModule('/src/view/rebar3d.ts'); + const { rebarSummary } = await server.ssrLoadModule('/src/core/rebar/summary.ts'); + const result = []; + for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]]) { + for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝']) { + const model = buildPier({ ...loadP1(), type, columnCount, foundationType }); + const record = { type, foundationType, columns: model.columns.length, footings: model.footings.length, piles: model.piles.length }; + for (const [name, run] of Object.entries({ concrete: () => buildPierScene(model), rebar3d: () => buildRebar3D(model), summary: () => rebarSummary(model) })) { + try { run(); record[name] = { ok: true }; } + catch (error) { record[name] = { ok: false, message: error.message, stack: error.stack.split('\n').slice(0, 5) }; } + } + result.push(record); + } + } + await mkdir('.verify/cast-in-place-20261105', { recursive: true }); + await writeFile('.verify/cast-in-place-20261105/diagnosis.json', JSON.stringify(result, null, 2)); + console.table(result.map(({ type, foundationType, concrete, rebar3d, summary }) => ({ type, foundationType, concrete: concrete.ok, rebar3d: rebar3d.ok, summary: summary.ok }))); +} finally { await server.close(); } diff --git a/scripts/verify-cast-detail.cjs b/scripts/verify-cast-detail.cjs new file mode 100644 index 0000000..100f493 --- /dev/null +++ b/scripts/verify-cast-detail.cjs @@ -0,0 +1,138 @@ +// Actual app verification in an isolated Windows Chrome, CDP port 9444. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const out = path.resolve(__dirname, '../.verify/cast-detail-20261105'); +fs.mkdirSync(out, { recursive: true }); +const report = { checks: [], errors: [] }; +const delay = ms => new Promise(r => setTimeout(r, 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((r, j) => { ws.onopen = r; ws.onerror = j; }); + let seq = 0, download; + const completed = new Set(), pending = new Map(); + ws.onmessage = ({ data }) => { + const m = JSON.parse(data), p = pending.get(m.id); + if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } + if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); + 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 < 120; 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: ' + ${JSON.stringify(expression)}); e.click(); })()`); + const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); }; + const file = async name => { + 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] }); + }; + const shot = async name => { await delay(250); 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 select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`); + const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`); + 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 = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-detail-20261105\\downloads'; + 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 b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); + const base = parseWorkbook(XLSX.read(b)).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:[]}; + })()`); + const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture)); + await ev('window.openSampleProject()'); + await click(`document.getElementById('open-project')`); await file(fixturePath); + await until(`document.querySelectorAll('#plan-pier-items button').length === 9`); + for (let i = 1; i <= 9; i++) { + const cast = [1,4,7].includes(i); + await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i); + await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`); + check(`P${i} plan: appropriate foundation controls`, await ev(cast + ? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')` + : `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`)); + await click(`document.getElementById('nav-rebar')`); await member('코핑'); + check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`)); + await member('기둥'); + check(`P${i} rebar: dedicated or legacy column`, await ev(cast + ? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled` + : `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`)); + } + await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1'); + await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`); + const ground = fixture.piers[0].input.groundEl, tip = ground-fixture.piers[0].input.pileLengthMm/1000; + check('clarified rock EL offsets and fixed beta default appear in the shape card', await ev(`Number(document.getElementById('cip-shape-betaLengthMm').value)===9000 && Number(document.getElementById('cip-shape-weatheredRockEl').value)===${tip+5} && Number(document.getElementById('cip-shape-bedrockEl').value)===${tip+1}`)); + check('independent clay EL is initially blank and casing fields inactive', await ev(`document.getElementById('cip-shape-clayBottomEl').value==='' && document.getElementById('cip-shape-casingTopEl').disabled`)); + await click(`document.getElementById('cip-casing-on')`); + await set('cip-shape-clayBottomEl',ground-5); + await ev(`document.getElementById('cip-shape-clayBottomEl').dispatchEvent(new Event('change'))`); + check('casing defaults follow ground plus 3 m and bedrock; independent clay persists', await ev(`!document.getElementById('cip-shape-casingTopEl').disabled && Number(document.getElementById('cip-shape-casingTopEl').value)===${ground+3} && Number(document.getElementById('cip-shape-casingBottomEl').value)===${tip+1} && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`)); + await click(`document.getElementById('member-regen')`); await shot('00-shape-decisions'); + await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('기둥'); + check('round-column two-layer defaults and coupler mode are populated', await ev(`document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-column-innerMainCount').value==='46' && document.getElementById('cip-column-coverReference').value==='main-center' && document.getElementById('cip-column-tieSpacingMm').value==='300' && document.getElementById('cip-connection-mode').value==='coupler'`)); + await member('현장타설말뚝'); + check('pile bottom cover defaults to 250 mm', await ev(`document.getElementById('cip-pile-endCoverMm').value==='250'`)); + await member('기둥'); + await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100); + await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`); + await until(`document.getElementById('cip-column-mainCount')?.value === '46'`); + await shot('01-column-input'); + await member('현장타설말뚝'); + await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`); + check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`)); + await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral'); + await click(`document.getElementById('cip-pile-apply')`); + await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`); + check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`)); + await shot('02-pile-input'); + await click(`document.getElementById('nav-quantity')`); + check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`)); + await shot('03-quantity-incomplete'); + 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), 'project download completes'); + const savedText = fs.readFileSync(path.join(downloads,download),'utf8'); + const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar; + const shape = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceShape; + check('project preserves explicit shape settings without freezing automatic rock ELs',shape.casingOn===true && shape.clayBottomEl===ground-5 && shape.bedrockEl===undefined); + check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous'); + 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 click(`document.getElementById('open-project')`); await file(savedPath); + await until(`document.querySelectorAll('#rebar-pier-items button').length===9`); + await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝'); + check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`)); + await member('기둥'); + check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`)); + await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1'); + await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`); + check('fresh reopen restores casing toggle and independent clay EL',await ev(`document.getElementById('cip-casing-on').checked && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`)); + check('no runtime exceptions in actual app',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,errors:report.errors,failure:report.failure})); + ws?.close(); +}); diff --git a/scripts/verify-cast-input.cjs b/scripts/verify-cast-input.cjs new file mode 100644 index 0000000..c985778 --- /dev/null +++ b/scripts/verify-cast-input.cjs @@ -0,0 +1,119 @@ +// Actual app verification in an isolated Windows Chrome, CDP port 9444. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const out = path.resolve(__dirname, '../.verify/cast-input-20261105'); +fs.mkdirSync(out, { recursive: true }); +const report = { checks: [], errors: [] }; +const delay = ms => new Promise(r => setTimeout(r, 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((r, j) => { ws.onopen = r; ws.onerror = j; }); + let seq = 0, download; + const completed = new Set(), pending = new Map(); + ws.onmessage = ({ data }) => { + const m = JSON.parse(data), p = pending.get(m.id); + if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } + if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); + 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 < 120; 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: ' + ${JSON.stringify(expression)}); e.click(); })()`); + const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); }; + const file = async name => { + 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] }); + }; + const shot = async name => { await delay(250); 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 select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`); + const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`); + 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 = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-input-20261105\\downloads'; + 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 b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); + const base = parseWorkbook(XLSX.read(b)).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:[]}; + })()`); + const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture)); + await ev('window.openSampleProject()'); + await click(`document.getElementById('open-project')`); await file(fixturePath); + await until(`document.querySelectorAll('#plan-pier-items button').length === 9`); + for (let i = 1; i <= 9; i++) { + const cast = [1,4,7].includes(i); + await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i); + await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`); + check(`P${i} plan: appropriate foundation controls`, await ev(cast + ? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')` + : `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`)); + await click(`document.getElementById('nav-rebar')`); await member('코핑'); + check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`)); + await member('기둥'); + check(`P${i} rebar: dedicated or legacy column`, await ev(cast + ? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled` + : `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`)); + } + await select('rebar-pier-items','P1'); await member('기둥'); + await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100); + await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`); + await until(`document.getElementById('cip-column-mainCount')?.value === '46'`); + await shot('01-column-input'); + await member('현장타설말뚝'); + await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`); + check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`)); + await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral'); + await click(`document.getElementById('cip-pile-apply')`); + await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`); + check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`)); + await shot('02-pile-input'); + await click(`document.getElementById('nav-quantity')`); + check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`)); + await shot('03-quantity-incomplete'); + 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), 'project download completes'); + const savedText = fs.readFileSync(path.join(downloads,download),'utf8'); + const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar; + check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous'); + 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 click(`document.getElementById('open-project')`); await file(savedPath); + await until(`document.querySelectorAll('#rebar-pier-items button').length===9`); + await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝'); + check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`)); + await member('기둥'); + check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`)); + check('no runtime exceptions in actual app',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,errors:report.errors,failure:report.failure})); + ws?.close(); +}); diff --git a/scripts/verify-cast-rebar.cjs b/scripts/verify-cast-rebar.cjs new file mode 100644 index 0000000..84ac1eb --- /dev/null +++ b/scripts/verify-cast-rebar.cjs @@ -0,0 +1,100 @@ +// Actual app: isolated Windows Chrome on CDP 9444, Vite on 5173. Run with Windows Node. +const fs=require('node:fs'), path=require('node:path'), assert=require('node:assert/strict'); +const out=path.resolve(__dirname,process.argv[2]||'../.verify/cast-rebar-20261106'); fs.mkdirSync(out,{recursive:true}); +const report={checks:[],errors:[],started:new Date().toISOString()}; +const delay=ms=>new Promise(r=>setTimeout(r,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((r,j)=>{ws.onopen=r;ws.onerror=j;}); + 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); + if(m.method==='Page.javascriptDialogOpening'&&m.params.type==='beforeunload')send('Page.handleJavaScriptDialog',{accept:true}).catch(e=>report.errors.push(e.message)); + }; + send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(new Error('Timeout '+method));},30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));}); + const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;}; + const until=async expression=>{for(let i=0;i<120;i++){if(await ev(expression))return;await delay(150);}throw new Error('Wait: '+expression);}; + const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing/disabled target');e.click();})()`); + 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 check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);}; + const shot=async name=>{await delay(250);const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(r.data,'base64'));}; + const open=async file=>{await click(`document.querySelector('#file-menu summary')`);await click(`document.getElementById('open-project')`);const doc=await send('DOM.getDocument');const{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#project-file'});await send('DOM.setFileInputFiles',{nodeId,files:[file]});}; + const pier=name=>click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`); + const member=text=>click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith(${JSON.stringify(text)}))`); + const table=`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r=>r.cells.length===14).map(r=>({name:r.cells[2].textContent,size:r.cells[3].textContent,grade:r.cells[4].textContent,count:Number(r.cells[11].textContent)}))`; + const downloads=path.join(out,'downloads');fs.mkdirSync(downloads,{recursive:true}); + await send('Page.enable');await send('Runtime.enable');await send('DOM.enable'); + await send('Browser.setDownloadBehavior',{behavior:'allowAndName',downloadPath:downloads,eventsEnabled:true}); + await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false}); + await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`); + const fixture=await ev(`(async()=>{ + const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts'); + const bytes=await(await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); + const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1'),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,groundEl:30,pileLengthMm:24000, + ...(foundationType==='현장타설말뚝'?{castInPlaceShape:{clayBottomEl:16},castInPlaceRebar:{column:{endZoneLengthMm:2000}}}:{})}; + piers.push({id:input.name,input,defaults:input,draft:input}); + }return{schemaVersion:1,project:{name:'현장타설 실배근 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'9개 합성 표본',sheets:[]},piers,history:[]}; + })()`); + const file=path.join(out,'fixture.pzr.json');fs.writeFileSync(file,JSON.stringify(fixture)); + await ev('window.openSampleProject()');await open(file);await until(`document.querySelectorAll('#rebar-pier-items button').length===9`); + await click(`document.getElementById('nav-rebar')`); + for(let i=1;i<=9;i++){ + await pier('P'+i);await member('기둥');const cast=[1,4,7].includes(i); + const rows=await ev(table); + check(`P${i}: appropriate real main and tie rows`,cast + ? ['기둥-P1','기둥-P2','말뚝-P1','말뚝-P2','기둥-P3','말뚝-P3','기둥-P5','말뚝-P5','접합-P1 커플러'].every(n=>rows.some(r=>r.name===n&&r.count>0)) + : ['P1','P2','P3','P5'].every(n=>rows.some(r=>r.name===n&&r.count>0))); + check(`P${i}: list reports actual support status`,await ev(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P${i} ·')).parentElement.textContent.includes('${cast||i>=4?'일부 계산':'계산됨'}')`)); + } + for(const i of [4,5,6,7,8,9]){ + await pier('P'+i);await member('코핑');const rows=await ev(table); + check(`P${i}: multi drawing diameters reach actual table`,[['C1','H25'],['C7','H29'],['C8','H25'],['C10','H22'],['C12','H16'],['C14','H16']].every(([name,size])=>rows.some(r=>r.name===name&&r.size===size&&r.count>0))); + check(`P${i}: multi defaults and remaining detail notice are visible`,await ev(`document.getElementById('coping-spacingS').value==='300'&&document.getElementById('coping-sideSpacing').value==='200'&&document.getElementById('coping-c14UpperSpacing').value==='600'&&document.getElementById('rebar-issues').textContent.includes('cycle별 이음')`)); + } + await shot('00-multi-coping'); + await pier('P1');await member('기둥'); + check('dedicated drawing uses generated front and section paths',await ev(`!!document.querySelector('[data-cast-part] figure svg polyline') && document.querySelectorAll('[data-cast-part] figure svg circle').length>50`)); + check('missing bottom P6 remains explicit rather than hiding all reinforcement',await ev(`document.getElementById('rebar-issues').textContent.includes('말뚝 저부 P6 검토안 미적용')`)); + await set('review-model-height',420);await shot('01-real-rebar'); + const baseline=await ev(table); + await set('cip-common-mainCount',50);await set('cip-common-mainDiameterMm',35.8);await set('cip-common-mainGradeMpa',400);await click(`document.getElementById('cip-apply')`); + const edited=await ev(table); + check('shared edits regenerate physical piece counts, diameters and grades',edited.filter(r=>r.name.endsWith('-P1')).every(r=>r.count%50===0&&r.count>=50&&r.size==='Ø35.8'&&r.grade==='SD400')); + check('regeneration changes actual quantities rather than just form values',JSON.stringify(edited)!==JSON.stringify(baseline)); + await set('cip-pile-tieKind','spiral');await click(`document.getElementById('cip-apply')`); + check('spiral produces its own real table rows', (await ev(table)).some(r=>r.name==='말뚝-P3 나선'&&r.count>0)); + await set('cip-pile-mainCount',48);await click(`document.getElementById('cip-apply')`); + check('mismatched joints remove interface couplers and report the mismatch',(await ev(table)).every(r=>!r.name.startsWith('접합-'))&&await ev(`document.getElementById('rebar-issues').textContent.includes('불일치')`)); + await set('cip-pile-mainCount',50);await click(`document.getElementById('cip-apply')`); + await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`); + check('pile table selection highlights the corresponding generated mark',await ev(`[...document.querySelectorAll('#rebar-table tr.hl')].some(r=>r.cells[2].textContent==='말뚝-P1')`)); + await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`); + await shot('02-pile-main-selected'); + await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`); + await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`); + await ev(`(()=>{const d=[...document.querySelectorAll('[data-cast-part] details')].find(d=>d.querySelector('summary').textContent.includes('삽도'));d.open=true;d.scrollIntoView({block:'start'});})()`);await shot('03-drawing'); + for(const width of [1280,1600,1920]){ + await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:false});await delay(150); + check(`header ${width} keeps one row and no horizontal overflow`,await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`)); + } + await click(`document.getElementById('nav-quantity')`); + check('quantity contains actual dedicated mains and reports remaining incomplete details',await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')&&document.getElementById('quantity-body').textContent.includes('기둥-P1')&&document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`)); + await shot('04-quantity'); + await click(`document.getElementById('nav-rebar')`);await member('기둥');const beforeSave=await ev(table); + 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 text=fs.readFileSync(path.join(downloads,download),'utf8'),savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,text); + const saved=JSON.parse(text).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar; + check('download preserves generating inputs',saved.column.mainCount===50&&saved.pile.mainCount===50&&saved.pile.tieKind==='spiral'&&saved.column.mainGradeMpa===400); + 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 pier('P1');await member('기둥'); + check('fresh-page restore regenerates identical physical bar rows',JSON.stringify(await ev(table))===JSON.stringify(beforeSave)); + 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/scripts/verify-multicol-coping.cjs b/scripts/verify-multicol-coping.cjs new file mode 100644 index 0000000..4d42160 --- /dev/null +++ b/scripts/verify-multicol-coping.cjs @@ -0,0 +1,76 @@ +// Run with Windows Node against the task's isolated Chrome CDP on port 9444. +const fs = require('node:fs'); +const path = require('node:path'); +const out = path.resolve(__dirname, '../.verify/multicol-coping-20261105'); +fs.mkdirSync(out, { recursive: true }); +(async () => { + const targets = await (await fetch('http://127.0.0.1:9444/json')).json(); + const page = targets.find(t => t.type === 'page' && t.url.startsWith('http://127.0.0.1:5173')); + if (!page) throw new Error('Task browser page missing'); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); + let seq = 0; + const pending = new Map(); + ws.onmessage = ({ data }) => { + const msg = JSON.parse(data); + const p = pending.get(msg.id); + if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); } + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 20000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); + try { + await send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 750, deviceScaleFactor: 1, mobile: false }); + const results = []; + for (const type of ['2주형', '3주형']) { + const res = await send('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: ` + (async () => { + const THREE = await import('/node_modules/.vite/deps/three.js'); + const XLSX = await import('/node_modules/xlsx/xlsx.mjs'); + const { parseWorkbook } = await import('/src/core/parseWorkbook.ts'); + const { buildPier } = await import('/src/core/geometry.ts'); + const { buildPierScene } = await import('/src/view/scene.ts'); + const buf = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); + const base = parseWorkbook(XLSX.read(buf)).piers.find(p => p.name === 'P1'); + if (!base) throw new Error('P1 sample missing'); + const m = buildPier({ ...base, type: ${JSON.stringify(type)}, columnCount: ${type === '2주형' ? 2 : 3} }); + const built = buildPierScene(m); + const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9eef4); + const group = new THREE.Group(); + group.add(built.parts.coping, built.parts.columns); scene.add(group); + const center = new THREE.Vector3(); + const bounds = new THREE.Box3().setFromObject(built.parts.coping); + bounds.getCenter(center); + // Align the camera along the cross-axis to make both interior haunches visible. + const u = new THREE.Vector3(m.u.e, 0, -m.u.n); + const cross = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n); + const camera = new THREE.PerspectiveCamera(36, 1200/750, 0.1, 1000); + const target = center.clone().add(new THREE.Vector3(0, -0.9, 0)); + camera.position.copy(target).addScaledVector(cross, m.copingL * 1.6).addScaledVector(u, -m.copingL * 0.12).add(new THREE.Vector3(0,-2.5,0)); + camera.lookAt(target); + scene.add(new THREE.HemisphereLight(0xffffff, 0x687784, 2.4)); + const light = new THREE.DirectionalLight(0xffffff, 2); light.position.copy(camera.position).add(new THREE.Vector3(0,10,0)); scene.add(light); + const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); + renderer.setSize(1200,750); renderer.localClippingEnabled = true; + document.body.replaceChildren(renderer.domElement); document.body.style.margin = '0'; + renderer.render(scene,camera); + const profile = m.copingParts[0].profile; + const raised = profile.filter(([,z]) => Math.abs(z-m.levelBottom-0.3)<1e-8).length; + if (raised !== (m.columns.length-1)*2) throw new Error('Raised span count mismatch'); + if (Math.abs(Math.min(...profile.map(p=>p[1]))-m.levelBottom)>1e-8) throw new Error('Underside below column Level'); + if (!m.columns.every(c=>c.top===m.levelBottom)) throw new Error('Column connection changed'); + return { type: ${JSON.stringify(type)}, raisedVertices: raised, levelBottom:m.levelBottom, interiorBottom:m.levelBottom+0.3, columns:m.columns.length, profile, screenshot:renderer.domElement.toDataURL('image/png') }; + })() + ` }); + if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text); + const { screenshot, ...result } = res.result.value; + fs.writeFileSync(path.join(out, `${type}.png`), Buffer.from(screenshot.split(',')[1], 'base64')); + results.push(result); + } + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(results, null, 2)); + console.log(JSON.stringify(results.map(({ profile, ...r }) => r))); + } finally { ws.close(); } +})().catch(e => { console.error(e); process.exitCode = 1; }); diff --git a/src/core/cast-in-place-shape.ts b/src/core/cast-in-place-shape.ts new file mode 100644 index 0000000..add046c --- /dev/null +++ b/src/core/cast-in-place-shape.ts @@ -0,0 +1,51 @@ +import type { PierInput } from './types'; + +/** EL in metres, lengths in mm. User image and clarifications, 2026-11-05. */ +export interface CastInPlaceShapeSettings { + betaLengthMm?: number; + weatheredRockEl?: number; + bedrockEl?: number; + casingOn?: boolean; + casingTopEl?: number; + casingBottomEl?: number; + clayBottomEl?: number; +} + +export function castShape(input: Pick) { + const s = input.castInPlaceShape ?? {}; + const pileBottomEl = input.groundEl - input.pileLengthMm / 1000; + const bedrockEl = s.bedrockEl ?? pileBottomEl + 1; + const betaLengthMm = s.betaLengthMm ?? 9000; + return { + pileTopEl: input.groundEl, pileBottomEl, + betaLengthMm, betaEl: input.groundEl - betaLengthMm / 1000, + weatheredRockEl: s.weatheredRockEl ?? pileBottomEl + 5, + bedrockEl, + casingOn: s.casingOn ?? false, + casingTopEl: s.casingTopEl ?? input.groundEl + 3, + casingBottomEl: s.casingBottomEl ?? bedrockEl, + clayBottomEl: s.clayBottomEl, + }; +} + +export function castShapeIssues(value: unknown): string[] { + if (value === undefined) return []; + if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 형상 입력 형식이 맞지 않습니다.']; + const out: string[] = []; + for (const [key, v] of Object.entries(value)) { + if (v === undefined) continue; + if (key === 'casingOn') { if (typeof v !== 'boolean') out.push('희생강관 적용 여부가 맞지 않습니다.'); } + else if (!['betaLengthMm', 'weatheredRockEl', 'bedrockEl', 'casingTopEl', 'casingBottomEl', 'clayBottomEl'].includes(key) || typeof v !== 'number' || !Number.isFinite(v) || key === 'betaLengthMm' && v <= 0) out.push(`현장타설 형상 ${key} 값이 맞지 않습니다.`); + } + return out; +} + +/** DXF note 4. Do not substitute weathered-rock EL for the independent clay-layer EL. */ +export function castLowerZone(input: Pick) { + const s = castShape(input); + if (s.clayBottomEl === undefined) return null; + const d = (input.columnDiameterMm ?? 2000) / 1000; + const a = s.betaEl - 3 * d; + const b = s.clayBottomEl + d; + return { bottomEl: a, topEl: b, valid: a < b }; +} diff --git a/src/core/changes.ts b/src/core/changes.ts index 26b71d2..45af6fd 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [ ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ -export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar', 'castInPlaceRebar', 'castInPlaceShape'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -59,6 +59,8 @@ export const FIELD_LABELS: Record = { columnRebar: '기둥 배근 입력', footingRebar: '확대기초 배근 입력', strataRows: '지층 행', + castInPlaceRebar: '현장타설 기둥·말뚝 배근 입력', + castInPlaceShape: '현장타설 암반·고정점·희생강관 입력', }; /** Keys whose values differ between two inputs. Arrays are compared by value. */ diff --git a/src/core/geometry.ts b/src/core/geometry.ts index b1b4434..3f2045b 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,13 +1,14 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block'; -import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings'; +import { type CopingRebarSettings, copingDefaults, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings'; import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings'; import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row, SolplatePlan } from './types'; import { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; +import { castShape } from './cast-in-place-shape'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ export const DIM = { @@ -15,7 +16,7 @@ export const DIM = { copingH1: 1.0, copingH2: 1.5, levelWidth: 2.1, - /** Multi-column coping: underside between columns sits this far below Level, with 45° haunches of the same size (spec §4.5, Q3 추천안). */ + /** Multi-column coping: underside between columns sits this far above Level, with 45° haunches of the same size (spec §4.4, Q3). */ haunch: 0.3, padThickness: 0.05, padSize: 1.0, @@ -207,6 +208,8 @@ export interface PierModel { blockHeightBack: number; frontBackDiffSpread: number; }; + /** Dedicated rebar source; never substitute footing-anchored column inputs. */ + castInput?: PierInput; } /** @@ -343,7 +346,7 @@ export function buildPier(input: PierInput): PierModel { const levelEnd = Math.max(...colG) + levelW / 2; if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.'); - // Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3). + // Underside from the right end to the left end. Between columns (2·3주형) it rises 300 above Level with 300 × 300 haunches (spec §4.4, Q3). const underside: [number, number][] = [[L, endBottom]]; const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const); levelZones.forEach(([zoneStart, zoneEnd], k) => { @@ -352,8 +355,8 @@ export function buildPier(input: PierInput): PierModel { if (!next) return; const gap = zoneStart - next[1]; if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.'); - const lowerEl = levelBottom - DIM.haunch; - underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]); + const raisedEl = levelBottom + DIM.haunch; + underside.push([zoneStart - DIM.haunch, raisedEl], [next[1] + DIM.haunch, raisedEl], [next[1], levelBottom]); }); // Elevation outline in coping coordinates for one row of tops, closed by the shared underside. @@ -394,7 +397,9 @@ export function buildPier(input: PierInput): PierModel { } } - const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + fd.H; + const cast = castShape(input); + if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.'); + const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H; const columns: ColumnModel[] = colG.map((g, i) => ({ s: g - base[colK[i]], k: colK[i], @@ -407,11 +412,11 @@ export function buildPier(input: PierInput): PierModel { const blindings: BlindingModel[] = []; const piles: PileModel[] = []; const pileLength = input.pileLengthMm / 1000; - const pileTip = input.footingBottomEl - pileLength; + const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength; if (input.foundationType === '현장타설말뚝') { columns.forEach((c) => { - piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: colD }); + piles.push({ s: c.s, k: c.k, n: 0, top: cast.pileTopEl, bottom: pileTip, diameter: colD }); }); } else { for (const f of footingSets) { @@ -487,7 +492,7 @@ export function buildPier(input: PierInput): PierModel { blocks, integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'), blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar }, - copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar }, + copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar }, topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice }, columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar }, footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar }, @@ -498,7 +503,7 @@ export function buildPier(input: PierInput): PierModel { groundEl: input.groundEl, derived: { columnHeight: levelBottom - columns[0].bottom, - soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop), + soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop, pileCount: piles.length, pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null, pileTipEl: piles.length ? pileTip : null, @@ -506,6 +511,7 @@ export function buildPier(input: PierInput): PierModel { blockHeightBack, frontBackDiffSpread, }, + ...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}), }; } diff --git a/src/core/project.ts b/src/core/project.ts index 837aeff..c115007 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -4,6 +4,8 @@ import type { PierInput } from './types'; import type { AbutRecord } from './abut/parse'; import { abutNameError } from './abut/name'; import { normalizeBearingAzimuths } from './coordinate-frame'; +import { castSettingsIssues } from './rebar/cast-in-place'; +import { castShapeIssues } from './cast-in-place-shape'; /** Project file schema (spec §8). Bump only with a migration. */ export const SCHEMA_VERSION = 1; @@ -95,6 +97,10 @@ export function parseProject(text: string): ProjectFile { } } for (const values of [p.input, p.defaults, p.draft]) { + const shapeIssues = castShapeIssues(values?.castInPlaceShape); + if (shapeIssues.length) throw new Error(`교각 ${input.name}: ${shapeIssues.join(' · ')}`); + const castIssues = castSettingsIssues(values?.castInPlaceRebar); + if (castIssues.length) throw new Error(`교각 ${input.name}: 현장타설 배근 · ${castIssues.join(' · ')}`); if (values?.bearingAzimuthFrame !== undefined && values.bearingAzimuthFrame !== 'model-xy') { throw new Error(`교각 ${input.name}: 받침 방위각 좌표계 표기가 맞지 않습니다.`); } diff --git a/src/core/rebar/cast-in-place.ts b/src/core/rebar/cast-in-place.ts new file mode 100644 index 0000000..cabb121 --- /dev/null +++ b/src/core/rebar/cast-in-place.ts @@ -0,0 +1,106 @@ +import type { PierInput } from '../types'; +import { DEFAULT_COLUMN_REBAR } from './column-settings'; +import { NOMINAL_DIAMETER, DEFAULT_MATERIAL, type Material } from './common'; + +/** Dedicated settings: round-column defaults were authorised by the user on 2026-11-05. */ +export interface CastRebarPart { + mainDiameterMm?: number; + mainCount?: number; + mainGradeMpa?: number; + coverMm?: number; + concreteStrengthMpa?: number; + tieDiameterMm?: number; + tieGradeMpa?: number; + tieSpacingMm?: number; + endZoneLengthMm?: number; + endZoneSpacingMm?: number; + cageLengthMm?: number; + endCoverMm?: number; + topEmbedmentMm?: number; + tieKind?: 'hoop' | 'spiral'; + coverReference?: 'clear' | 'main-center'; + innerMainDiameterMm?: number; + innerMainCount?: number; +} + +export interface CastInPlaceRebar { + column?: CastRebarPart; + pile?: CastRebarPart; + connection?: { + mode?: 'continuous' | 'lap' | 'coupler'; + lapLengthMm?: number; + couplerLengthMm?: number; + }; +} + +export const CAST_PART_FIELDS: { key: Exclude; label: string; only?: 'column' | 'pile' }[] = [ + { key: 'mainDiameterMm', label: '외측 주철근 공칭지름 (mm)' }, + { key: 'mainCount', label: '외측 주철근 본수 (본)' }, + { key: 'mainGradeMpa', label: '주철근 강도 fy (MPa)' }, + { key: 'coverMm', label: '측면 피복 (아래 기준 선택, mm)' }, + { key: 'concreteStrengthMpa', label: '콘크리트 강도 fck (MPa)' }, + { key: 'tieDiameterMm', label: '띠·나선철근 직경 (mm)' }, + { key: 'tieGradeMpa', label: '띠·나선철근 강도 fy (MPa)' }, + { key: 'tieSpacingMm', label: '일반구간 띠 간격·나선 피치 (mm)' }, + { key: 'endZoneLengthMm', label: '단부 보강구간 길이 (mm)' }, + { key: 'endZoneSpacingMm', label: '단부 보강구간 간격 (mm)' }, + { key: 'cageLengthMm', label: '말뚝 철근망 길이 (mm)', only: 'pile' }, + { key: 'endCoverMm', label: '말뚝 선단 순피복 (mm)', only: 'pile' }, + { key: 'topEmbedmentMm', label: '코핑 내부 주철근 연장 길이 (mm)', only: 'column' }, + { key: 'innerMainDiameterMm', label: '내측 주철근 공칭지름 (mm)' }, + { key: 'innerMainCount', label: '내측 주철근 본수 (본)' }, +]; + +export function castRebarDefaults(input: Pick, material: Material = DEFAULT_MATERIAL): CastInPlaceRebar { + const c = { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar }; + const saved = input.castInPlaceRebar ?? {}; + const part = (key: 'column' | 'pile'): CastRebarPart => ({ + mainDiameterMm: NOMINAL_DIAMETER.S32, mainCount: c.nDiv, mainGradeMpa: material.grade.S, + innerMainDiameterMm: NOMINAL_DIAMETER.S32, innerMainCount: c.nDiv, + coverMm: c.c, coverReference: saved[key]?.coverMm !== undefined && saved[key]?.coverReference === undefined ? 'clear' : 'main-center', + concreteStrengthMpa: material.fck, + tieDiameterMm: NOMINAL_DIAMETER.H19, tieGradeMpa: material.grade.H, tieSpacingMm: c.ctcP3, + endZoneSpacingMm: c.ctcP5, tieKind: 'hoop', + ...(key === 'pile' ? { endCoverMm: 250 } : {}), + ...saved[key], + }); + return { column: part('column'), pile: part('pile'), connection: { mode: 'coupler', couplerLengthMm: 150, ...saved.connection } }; +} + +export const CAST_PENDING = ['현장타설 기둥 배근 상세 미확정', '현장타설말뚝 배근 상세 미확정', '기둥·말뚝 접합부 배근 상세 미확정']; + +/** Storage and input validation; domain-specific spacing/anchorage rules remain unconfirmed. */ +export function castSettingsIssues(value: unknown, pileLengthMm?: number, diameterMm?: number): string[] { + if (value === undefined) return []; + const issues: string[] = []; + if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 배근 입력 형식이 맞지 않습니다.']; + const record = value as Record; + if (Object.keys(record).some(k => !['column', 'pile', 'connection'].includes(k))) issues.push('알 수 없는 현장타설 배근 항목입니다.'); + for (const part of ['column', 'pile', 'connection'] as const) { + const values = record[part]; + if (values === undefined) continue; + if (!values || typeof values !== 'object' || Array.isArray(values)) { issues.push(`${part}: 입력 형식이 맞지 않습니다.`); continue; } + for (const [key, v] of Object.entries(values)) { + if (v === undefined) continue; + if (part === 'connection' && key === 'mode') { + if (typeof v !== 'string' || !['continuous', 'lap', 'coupler'].includes(v)) issues.push('접합 방식이 맞지 않습니다.'); + } else if (part !== 'connection' && key === 'tieKind') { + if (typeof v !== 'string' || !['hoop', 'spiral'].includes(v)) issues.push('횡철근 형식이 맞지 않습니다.'); + } else if (part !== 'connection' && key === 'coverReference') { + if (typeof v !== 'string' || !['clear', 'main-center'].includes(v)) issues.push('피복 기준이 맞지 않습니다.'); + } else { + const allowed = part === 'connection' ? ['lapLengthMm', 'couplerLengthMm'] : CAST_PART_FIELDS.filter(f => !f.only || f.only === part).map(f => f.key); + if (!allowed.includes(key) || typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || (key.endsWith('Count') && !Number.isInteger(v))) issues.push(`${part}.${key}: 양수${key.endsWith('Count') ? ' 정수' : ''}로 입력하세요.`); + } + } + if (part === 'pile') { + const p = values as CastRebarPart; + if (pileLengthMm !== undefined && p.cageLengthMm !== undefined && p.cageLengthMm > pileLengthMm) issues.push('말뚝 철근망 길이가 말뚝 길이보다 큽니다.'); + } + if (part !== 'connection') { + const p = values as CastRebarPart; + if (diameterMm !== undefined && p.coverMm !== undefined && p.coverMm * 2 >= diameterMm) issues.push(`${part}: 피복이 단면 반지름 이상입니다.`); + } + } + return issues; +} diff --git a/src/core/rebar/cast-layout.ts b/src/core/rebar/cast-layout.ts new file mode 100644 index 0000000..be5419f --- /dev/null +++ b/src/core/rebar/cast-layout.ts @@ -0,0 +1,222 @@ +import type { PierModel } from '../geometry'; +import { castLowerZone } from '../cast-in-place-shape'; +import { castRebarDefaults, castSettingsIssues, type CastRebarPart } from './cast-in-place'; +import { DEFAULT_MATERIAL, developmentLength, hookLength, spliceLength, type Material } from './common'; + +export type CastPoint = [number, number, number]; +/** One physical piece. Coordinates use the existing rebar scene frame, in metres. */ +export interface CastBar { + name: string; + member: 'column' | 'pile'; + kind: 'main' | 'tie' | 'crosstie' | 'coupler'; + k: number; + pts: CastPoint[]; + diameterMm: number; + gradeMpa: number; + lengthMm: number; + note: string; + columnIndex: number; +} +export interface CastLayout { bars: CastBar[]; missing: string[] } + +/** Shared by the renderer, drawings and quantities; no meshes or UI state here. */ +export function castRebarLayout(model: PierModel, material: Material = DEFAULT_MATERIAL): CastLayout { + const bars: CastBar[] = []; + const missing = new Set(); + if (!model.castInput) return { bars, missing: [] }; + const input = model.castInput; + const saved = castRebarDefaults(input, material); + const issues = castSettingsIssues(saved, input.pileLengthMm, model.columnDiameterMm); + if (issues.length) return { bars, missing: issues }; + const parts = ['column', 'pile'] as const; + const names = { column: '기둥', pile: '말뚝' }; + const joint = model.groundEl; + const D = model.columnDiameterMm; + const pitch = model.columnRebar.ctcCoupler; + const stagger = model.columnRebar.dzCp2; + const mode = saved.connection!.mode; + const couplingLength = saved.connection!.couplerLengthMm; + const lap = saved.connection!.lapLengthMm; + let owner = 0; + const add = (bar: Omit) => { + if (bar.lengthMm > 0 && Number.isFinite(bar.lengthMm)) bars.push({ ...bar, columnIndex: owner }); + }; + const radii = (p: CastRebarPart) => { + const outer = D / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0); + return { outer, inner: outer - (p.mainDiameterMm! + p.innerMainDiameterMm!) / 2, tie: outer + (p.mainDiameterMm! + p.tieDiameterMm!) / 2 }; + }; + const r = { column: radii(saved.column!), pile: radii(saved.pile!) }; + const aligned = (['outer', 'inner'] as const).every((layer, i) => { + const count = i ? 'innerMainCount' : 'mainCount'; + const diameter = i ? 'innerMainDiameterMm' : 'mainDiameterMm'; + return saved.column![count] === saved.pile![count] && saved.column![diameter] === saved.pile![diameter] + && saved.column!.mainGradeMpa === saved.pile!.mainGradeMpa && Math.abs(r.column[layer] - r.pile[layer]) < 0.01; + }); + if (!aligned) missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산'); + const connected = aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength || mode === 'lap' && !!lap); + if (!mode || mode === 'coupler' && !couplingLength || mode === 'lap' && !lap) missing.add('접합부 연결 방식 또는 연결 길이 미입력'); + if (mode === 'lap') missing.add('겹침이음의 횡방향 배치 상세 미확정 · 겹침부 미계산'); + missing.add('말뚝 저부 P6 검토안 미적용'); + + model.columns.forEach((column, columnIndex) => { + owner = columnIndex; + const pile = model.piles[columnIndex]; + if (!pile) { missing.add('기둥에 대응하는 현장타설말뚝 없음'); return; } + const g = column.s + model.stretches[column.k].base; + const segment = model.segments.find(s => g >= s.s0 && g <= s.s1) ?? model.segments[0]; + const c3 = model.copingRefTop - 0.127 - 2 * model.copingRebar.mainLayerGap / 1000; + const point = (radius: number, angle: number, height: number): CastPoint => [column.s + radius / 1000 * Math.cos(angle), height, radius / 1000 * Math.sin(angle)]; + const bottomCover = saved.pile!.endCoverMm!; + const mainRadius = Math.max(saved.pile!.mainDiameterMm!, saved.pile!.innerMainDiameterMm!) / 2; + const cageBottom = saved.pile!.cageLengthMm === undefined ? pile.bottom + (bottomCover + mainRadius) / 1000 : joint - saved.pile!.cageLengthMm / 1000; + if (cageBottom < pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 || cageBottom >= joint) { + missing.add('말뚝 철근망 하단이 선단 피복 또는 말뚝 범위를 벗어남'); + } + const pileValid = cageBottom >= pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 && cageBottom < joint; + const ext = saved.column!.topEmbedmentMm; + const tops = [ext === undefined ? c3 - 0.1 : column.top + ext / 1000, + column.top + (ext ?? developmentLength(saved.column!.innerMainDiameterMm!, saved.column!.mainGradeMpa!, saved.column!.concreteStrengthMpa!)) / 1000]; + const validRadii = (part: 'column' | 'pile') => { + const p = saved[part]!; + return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= D / 2 + 1e-8; + }; + for (const part of parts) { + if (!validRadii(part)) { missing.add(`${names[part]} 철근 단면·피복 범위 오류`); continue; } + if (part === 'pile' && !pileValid) continue; + const p = saved[part]!; + if (p.mainCount! > 2000 || p.innerMainCount! > 2000) { missing.add(`${names[part]} 주철근 본수 표시 한도 초과`); continue; } + for (const [layerIndex, layer] of (['outer', 'inner'] as const).entries()) { + const diameter = layerIndex ? p.innerMainDiameterMm! : p.mainDiameterMm!; + const count = layerIndex ? p.innerMainCount! : p.mainCount!; + const top = tops[layerIndex]; + if (part === 'column' && (top <= joint || top + diameter / 2000 > segment.top)) { + missing.add(`${names[part]} P${layerIndex + 1} 상단 정착이 코핑 범위를 벗어남`); continue; + } + const offset = layerIndex ? stagger / 1000 : 0; + const boundary = connected && mode === 'coupler' ? joint + offset : joint; + const start = part === 'column' ? boundary : cageBottom; + const end = part === 'column' ? top : boundary; + const combined = connected && mode === 'continuous' && pileValid && validRadii('pile'); + if (combined) missing.add('연속 주철근 제작·시공 이음 상세 미확정'); + if (combined && part === 'pile') continue; + const lo = combined ? cageBottom : start; + if (end <= lo) { missing.add(`${names[part]} 주철근 유효 길이 없음`); continue; } + const cuts: number[] = []; + // Periodic column joints reuse the existing approved pitch and stagger; no footing reference. + if (mode === 'coupler' && couplingLength && pitch > 0) { + for (let h = joint + offset + pitch / 1000; h < end - 2.5; h += pitch / 1000) if (h > lo) cuts.push(h); + for (let h = joint + offset - pitch / 1000; h > lo + couplingLength / 1000; h -= pitch / 1000) if (h < end) cuts.push(h); + } + const levels = [lo, ...cuts.sort((a, b) => a - b), end]; + for (let i = 0; i < count; i++) { + const angle = 2 * Math.PI * i / count; + const name = `${combined ? '기둥·말뚝' : names[part]}-P${layerIndex + 1}`; + levels.slice(1).forEach((h, j) => add({ name, member: part, kind: 'main', k: column.k, + pts: [point(r[part][layer], angle, levels[j]), point(r[part][layer], angle, h)], diameterMm: diameter, + gradeMpa: p.mainGradeMpa!, lengthMm: (h - levels[j]) * 1000, note: combined ? '연속 주철근 · 기초 갈고리 없음' : '현장타설 전용 주철근 · 마디별 집계' })); + const coupler = (h: number, label: string) => { + if (couplingLength! > pitch || h - couplingLength! / 2000 < cageBottom || h + couplingLength! / 2000 > top) { + missing.add('주철근 커플러 길이가 배치 가능 범위를 벗어남'); return; + } + if (Math.abs(diameter - 31.8) > 0.05) missing.add('주철근 커플러 외경은 표시 가정 · 실제 규격 확인 필요'); + add({ name: label, member: part, kind: 'coupler', k: column.k, + pts: [point(r[part][layer], angle, h - couplingLength! / 2000), point(r[part][layer], angle, h + couplingLength! / 2000)], + diameterMm: diameter * 60 / 31.8, gradeMpa: p.mainGradeMpa!, lengthMm: couplingLength!, note: '주철근 중심 맞댐 · 외경은 기존 D32용 비율의 표시 가정' }); + }; + for (const h of cuts) coupler(h, `${names[part]}-P${layerIndex + 1} 커플러`); + if (part === 'column' && connected && mode === 'coupler' && pileValid && validRadii('pile')) coupler(boundary, `접합-P${layerIndex + 1} 커플러`); + } + } + const upper = part === 'column' ? (column.top + segment.top) / 2 : joint; + const lower = part === 'column' ? joint : cageBottom + p.tieDiameterMm! / 2000; + let zone: [number, number] | undefined; + if (part === 'column' && p.endZoneLengthMm !== undefined) zone = [Math.max(lower, column.top - p.endZoneLengthMm / 1000), upper]; + if (part === 'pile') { + const geo = castLowerZone(input); + const bottom = joint - (input.castInPlaceShape?.betaLengthMm ?? 9000) / 1000 - 3 * D / 1000; + if (p.endZoneLengthMm !== undefined) zone = [bottom, bottom + p.endZoneLengthMm / 1000]; + else if (geo?.valid) zone = [geo.bottomEl, geo.topEl]; + else if (geo) missing.add('말뚝 하단 단부구역 경계 역전'); + } + if (!zone) missing.add(`${names[part]} 단부구간 경계 미입력 · 일반 횡철근만 계산`); + if (zone && (zone[0] < lower || zone[1] > upper || zone[1] <= zone[0])) { + missing.add(`${names[part]} 단부구간이 철근망 범위를 벗어남`); zone = undefined; + } + const radius = r[part].tie; + const tieNote = '일반/단부 경계에서 구간별 반간격 시작 · 겹침 원형 이음'; + const intervals: { from: number; to: number; step: number; mark: string }[] = []; + const ordinary = (from: number, to: number) => { if (to > from) intervals.push({ from, to, step: p.tieSpacingMm!, mark: 'P3' }); }; + const zones = zone ? [zone] : []; + // A column upper-end region that crosses ground continues on the pile side, with pile-section properties. + const columnEnd = saved.column!.endZoneLengthMm; + if (part === 'pile' && columnEnd !== undefined && column.top - columnEnd / 1000 < joint) { + zones.push([Math.max(lower, column.top - columnEnd / 1000), upper]); + } + zones.sort((a, b) => a[0] - b[0]); + const merged: [number, number][] = []; + for (const next of zones) { + const last = merged.at(-1); + if (last && next[0] <= last[1]) last[1] = Math.max(last[1], next[1]); + else merged.push([...next]); + } + let cursor = lower; + for (const dense of merged) { + ordinary(cursor, dense[0]); intervals.push({ from: dense[0], to: dense[1], step: p.endZoneSpacingMm!, mark: 'P5' }); cursor = dense[1]; + } + ordinary(cursor, upper); + for (const interval of intervals) { + const { from, to, step, mark } = interval; + if (step <= 0 || !Number.isFinite(step)) continue; + if ((to - from) * 1000 / step > 5000) { missing.add(`${names[part]} 횡철근 개수 과다 · 간격 확인 필요`); continue; } + const lapLength = mark === 'P5' ? 0 : spliceLength(p.tieDiameterMm!, p.tieGradeMpa!, p.concreteStrengthMpa!); + if (lapLength >= 2 * Math.PI * radius) { missing.add(`${names[part]} 횡철근 겹침길이가 원주 이상 · 재료 입력 확인 필요`); continue; } + const makeTie = (pts: CastPoint[], lengthMm: number, note: string) => add({ name: `${names[part]}-${mark}${p.tieKind === 'spiral' ? ' 나선' : ''}`, + member: part, kind: 'tie', k: column.k, pts, diameterMm: p.tieDiameterMm!, gradeMpa: p.tieGradeMpa!, lengthMm, note }); + const a = from + step / 2000; + const b = to - p.tieDiameterMm! / 2000; + if (b <= a) { missing.add(`${names[part]} 횡철근 구간 길이 부족`); continue; } + if (p.tieKind === 'spiral') { + const turns = (b - a) * 1000 / step; + const n = Math.ceil(turns * 32); + const pts = Array.from({ length: n + 1 }, (_, j) => point(radius, 2 * Math.PI * turns * j / n, a + (b - a) * j / n)); + makeTie(pts, Math.hypot(2 * Math.PI * radius * turns, (b - a) * 1000), '나선 중심선 · 단부 정착·구간 전이 상세 미확정'); + missing.add(`${names[part]} 나선철근 단부 정착·구간 전이 미확정`); + } else { + const totalAngle = 2 * Math.PI + lapLength / radius; + for (let h = a, index = 0; h <= b + 1e-9; h += step / 1000, index++) { + const pts = Array.from({ length: 65 }, (_, j) => point(radius, index % 2 * Math.PI + totalAngle * j / 64, h)); + makeTie(pts, 2 * Math.PI * radius + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : tieNote); + if (mark === 'P5') { + const angle = index % 2 * Math.PI + Math.PI / p.mainCount!; + const half = 50 / radius; + add({ name: `${names[part]}-P5 띠철근 커플러`, member: part, kind: 'coupler', k: column.k, + pts: [point(radius, angle - half, h), point(radius, angle + half, h)], diameterMm: 40, + gradeMpa: p.tieGradeMpa!, lengthMm: 100, note: '기존 H19용 L100·Ø40 폐합 상세' }); + if (Math.abs(p.tieDiameterMm! - 19.1) > 0.05) missing.add(`${names[part]} 단부 띠철근 커플러 규격 확인 필요`); + } + } + } + } + // Only the column crossties are reused. Pile-bottom U bars remain a separate pending design. + if (part === 'column') { + if (p.mainCount! < 8 || p.mainCount! % 2) { missing.add('기둥 P6 대칭쌍 배치에는 8본 이상 짝수 외측 주철근이 필요'); continue; } + const diameter = 15.9; + const hook = Math.round(hookLength('tie90', diameter)); + for (let h = lower + Math.max(p.tieSpacingMm! / 2, hook + diameter / 2) / 1000; h + 3.5 * diameter / 1000 <= upper; h += model.columnRebar.ctcP6 / 1000) { + for (let j = 0; j < 4; j++) { + const index = Math.round(j * p.mainCount! / 8); + const angle = index * 2 * Math.PI / p.mainCount!; + const y = h + j * diameter / 1000; + const a = point(radius, angle, y), b = point(radius, angle + Math.PI, y); + add({ name: '기둥-P6 크로스타이', member: part, kind: 'crosstie', k: column.k, + pts: [[a[0], a[1] - hook / 1000, a[2]], a, b, [b[0], b[1] - hook / 1000, b[2]]], + diameterMm: diameter, gradeMpa: material.grade.H, lengthMm: 2 * radius + 2 * hook, + note: '기둥 크로스타이 @기존 P6간격 · 말뚝 저부 P6와 별개' }); + } + } + missing.add('기둥 P6 시작단·적층·굽힘 상세 확인 필요'); + } + } + }); + return { bars, missing: [...missing] }; +} diff --git a/src/core/rebar/color.ts b/src/core/rebar/color.ts index eed472f..a834d6b 100644 --- a/src/core/rebar/color.ts +++ b/src/core/rebar/color.ts @@ -5,6 +5,7 @@ export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'othe /** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */ export function memberOfBar(name: string): RebarMemberKey { + if (/^(기둥|말뚝|접합)/.test(name)) return 'column'; if (name.includes('커플러') || /^P\d/.test(name)) return 'column'; if (/^C\d/.test(name)) return 'coping'; if (/^B\d/.test(name)) return 'footing'; diff --git a/src/core/rebar/column.ts b/src/core/rebar/column.ts index 5a7380f..9776efa 100644 --- a/src/core/rebar/column.ts +++ b/src/core/rebar/column.ts @@ -39,6 +39,8 @@ export interface ColumnInput { /** P4 구간 깊이 비율 r_P4. */ rP4: number; ctcP6: number; + /** 다주형 삽도의 상·하단부 합집합. 번호는 기존 역할 대응을 유지한다. */ + bothEndZones?: boolean; } /** 예제 운곡교 P1 (기둥철근.md §1.2 기본값). */ @@ -47,18 +49,22 @@ export interface ColumnInput { * h_cop = 계단 상면(기둥 중심 구간의 상면) − Level 하면 (2.644 on P1, 43.555 − 40.911). Only these two follow the model; * the other values stay as in COLUMN_P1. */ -export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1): ColumnInput { - const col = model.columns[0]; - const seg = model.segments.find((g) => col.s >= g.s0 - 1e-9 && col.s <= g.s1 + 1e-9) ?? model.segments[0]; - const Hc = Math.round((model.levelBottom - model.footings[0].top) * 1000 * 10) / 10; - const hcop = Math.round((seg.top - model.levelBottom) * 1000 * 10) / 10; +export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1, columnIndex = 0): ColumnInput { + if (!model.footings.length) throw new Error('현장타설말뚝 기둥은 전용 배근 입력을 사용합니다. 확대기초 정착 규칙은 적용하지 않습니다.'); + const col = model.columns[columnIndex]; + const globalS = col.s + model.stretches[col.k].base; + const seg = model.segments.find((g) => globalS >= g.s0 - 1e-9 && globalS <= g.s1 + 1e-9) ?? model.segments[0]; + const footing = model.footings.find(f => f.k === col.k && Math.abs(f.s - col.s) <= f.length / 2)!; + const Hc = Math.round((col.top - col.bottom) * 1000 * 10) / 10; + const hcop = Math.round((seg.top - col.top) * 1000 * 10) / 10; // C3 (상면 3단) = the lowest coping top −127 (C1 cover) −2·s_lay (상면 주철근 단 간격 input); P1 was 43.170 at 57.2. // P1 top = C3 − 100 (계산 2026-10-23), so e_P1 = step top − (C3 − 0.1) = 485 on P1. const c3 = model.copingRefTop - 0.127 - 2 * (model.copingRebar.mainLayerGap / 1000); const eP1 = Math.round((seg.top - (c3 - 0.1)) * 1000); // e_top: the coping middle level (Level 하면 + 계단 상면)/2 is the top of P3 and P6 (승인 2026-10-23). - const eTop = Math.round((seg.top - (model.levelBottom + seg.top) / 2) * 1000); - return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm }; + const eTop = Math.round((seg.top - (col.top + seg.top) / 2) * 10000) / 10; + return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm, + Hf: (footing.top - footing.bottom) * 1000, bothEndZones: model.columns.length > 1 }; } export const COLUMN_P1: ColumnInput = { @@ -142,9 +148,17 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA const lEmin = Math.max(x.D, zTopT / 6, 450); const n5 = Math.floor(lEmin / x.ctcP5); const lE = Math.max(lEmin, n5 * x.ctcP5 + x.ctcP5 / 2); - const p3 = Array.from({ length: Math.floor((zTopT - lE) / x.ctcP3) + 1 }, (_, k) => lE + k * x.ctcP3); + let p3 = Array.from({ length: Math.max(0, Math.floor((zTopT - lE) / x.ctcP3) + 1) }, (_, k) => lE + k * x.ctcP3); const n5all = Math.floor(lE / x.ctcP5); - const p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5); + let p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5); + if (x.bothEndZones) { + // Drawing priority: at least D below the coping, plus half the coping height above it. + // Existing lower-end minimum is retained. Overlapping ends become one dense interval. + const upperStart = Math.max(0, x.Hc - lE); + const intervals = upperStart <= lE ? [[0, zTopT]] : [[0, lE], [upperStart, zTopT]]; + p5 = intervals.flatMap(([a, b]) => Array.from({ length: Math.max(0, Math.floor((b - a) / x.ctcP5)) }, (_, i) => a + x.ctcP5 / 2 + i * x.ctcP5)); + p3 = upperStart <= lE ? [] : Array.from({ length: Math.max(0, Math.floor((upperStart - lE) / x.ctcP3)) }, (_, i) => lE + x.ctcP3 / 2 + i * x.ctcP3); + } const z5last = lE - n5all * x.ctcP5; const m4 = Math.floor((z5last + x.rP4 * x.Hf) / x.ctcP4); const p4All = Array.from({ length: m4 }, (_, j) => z5last - (j + 1) * x.ctcP4); diff --git a/src/core/rebar/coping-settings.ts b/src/core/rebar/coping-settings.ts index b416927..90210b3 100644 --- a/src/core/rebar/coping-settings.ts +++ b/src/core/rebar/coping-settings.ts @@ -44,6 +44,12 @@ export const DEFAULT_COPING_REBAR: CopingRebarSettings = { c14LowerSpacing: 2000, }; +/** 1106 ‘2주형 3주형’ 전용 삽도 우선(사용자 선택). 명시 입력은 이 기본값보다 우선한다. */ +export function copingDefaults(columnCount: number): CopingRebarSettings { + return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, spacingS: 300, spacingL: 300, + sideSpacing: 200, c14UpperSpacing: 600, c14LowerSpacing: 600 } : { ...DEFAULT_COPING_REBAR }; +} + /** Allowed range per input (inclusive, mm). The shear legs are checked for evenness separately (CP02). */ export const COPING_REBAR_RANGES: Record = { cover: [80, 150], @@ -56,8 +62,8 @@ export const COPING_REBAR_RANGES: Record p.k === 0).map(p => p.profile); + const xs = [...new Set(polygons.flatMap(p => p.map(v => v[0])))].sort((a, b) => a - b); + const under = (x: number) => Math.max(...polygons.map(p => { + const hits = p.flatMap((a, i) => { + const b = p[(i + 1) % p.length]; + if (Math.abs(b[0] - a[0]) < 1e-9 || x < Math.min(a[0], b[0]) - 1e-9 || x > Math.max(a[0], b[0]) + 1e-9) return []; + return [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])]; + }); + return Math.min(...hits); + })); + const slopes = xs.slice(1).map((x, i) => (under(x) - under(xs[i])) / (x - xs[i])); + // Conservative vertical inset at a corner: both adjoining faces retain the requested normal cover. + const inset = (x: number, cover: number) => under(x) + cover * Math.max(1, ...slopes.filter((_, i) => x >= xs[i] - 1e-9 && x <= xs[i + 1] + 1e-9).map(s => Math.hypot(1, s))); + return { xs, under, inset }; +} + +export function copingPathLength(pts: CopingPoint[]): number { + return pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - pts[i][j])), 0) * 1000; +} + +/** Shared physical pieces for multi-column 3D and quantities. T-shaped legacy layout is unchanged. */ +export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] { + const bars: CopingBar[] = []; + const L = model.copingL, B = model.copingB, top = model.copingRefTop; + const settings = model.copingRebar; + if ([settings.spacingS, settings.spacingL, settings.sideSpacing, settings.c14UpperSpacing, settings.c14LowerSpacing].some(v => !Number.isFinite(v) || v <= 0) + || !Number.isInteger(settings.shearLegs) || settings.shearLegs < 2 || settings.shearLegs > 8 || settings.shearLegs % 2) { + throw new Error('다주형 배근 간격은 양수, 전단 다리 수는 2~8의 짝수여야 합니다.'); + } + const envelope = copingEnvelope(model); + const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => { + if (pts.length < 2) return; + bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) }); + }; + const width = widthLayout(B * 1000, settings); + const ns = [width.yE, ...Array.from({ length: width.n + 1 }, (_, i) => width.yE + width.g + width.spacing * i), B * 1000 - width.yE].map(v => v / 1000 - B / 2); + // C5/C6 correspondence and cycle-specific splice locations remain explicitly pending. + for (let k = 1; k <= 3; k++) for (const [j, n] of ns.entries()) { + const name = `C${k + (j % 2 ? 0 : 3)}`; + const size = Number(name.slice(1)) <= 4 ? 'H25' : 'H29'; + const d = NOMINAL_DIAMETER[size]; + const a = settings.cover / 1000 + (k - 1) * d / 1000, b = L - a; + const z = top - 0.127 - (k - 1) * settings.mainLayerGap / 1000; + const hook = hookLength('main90', d) / 1000; + const total = (b - a + 2 * hook) * 1000; + const lap = spliceLength(d, fyOf(material, size), material.fck) / 1000; + if (model.topSplice.mode === 'apply' && total > 12000) { + const split = model.topSplice.at === 'center' ? L / 2 : b + hook + lap / 2 - 12; + add(name, size, [[a, z - hook, n], [a, z, n], [split + lap / 2, z, n]]); + add(name, size, [[split - lap / 2, z, n], [b, z, n], [b, z - hook, n]]); + } else add(name, size, [[a, z - hook, n], [a, z, n], [b, z, n], [b, z - hook, n]]); + } + for (const [j, n] of ns.entries()) { + const name = j % 2 ? 'C7' : 'C8', size = j % 2 ? 'H29' : 'H25'; + const lap = spliceLength(NOMINAL_DIAMETER[size], fyOf(material, size), material.fck) / 2000; + const a = settings.cover / 1000, b = L - a; + const z = (x: number) => envelope.inset(x, (settings.cover + width.yE - 100) / 1000); + const path = (from: number, to: number): CopingPoint[] => [from, ...envelope.xs.filter(x => x > from && x < to), to].map(x => [x, z(x), n]); + add(name, size, [[a, top - 0.127, n], ...path(a, L / 2 + lap)]); + add(name, size, [...path(L / 2 - lap, b), [b, top - 0.127, n]]); + } + // Plan groups retain their functional IDs; the drawing also uses C13 for a shear loop. + const columns = model.columns.map(c => c.s).sort((a, b) => a - b); + const half = model.copingGeom.levelWidth / 2000; + const kMax = Math.ceil(half * 1000 / settings.spacingL); + const groups = columns.map(c => Array.from({ length: 2 * kMax + 1 }, (_, i) => c + (i - kMax) * settings.spacingL / 1000)); + const positions: { name: string; x: number }[] = groups.flatMap(g => g.map(x => ({ name: 'C10', x }))); + const gap = (settings.cover + settings.firstShearGap) / 1000; + for (let x = groups[0][0] - settings.spacingS / 1000; x >= gap - 1e-9; x -= settings.spacingS / 1000) positions.push({ name: 'C9', x }); + for (let x = groups.at(-1)!.at(-1)! + settings.spacingS / 1000; x <= L - gap + 1e-9; x += settings.spacingS / 1000) positions.push({ name: 'C11', x }); + for (let i = 1; i < groups.length; i++) { + const a = groups[i - 1].at(-1)!, b = groups[i][0], count = Math.floor((b - a) * 1000 / settings.spacingS); + for (let j = 1; j < count; j++) positions.push({ name: 'C9·C11 사이', x: a + (b - a) * j / count }); + } + const ds = NOMINAL_DIAMETER.H22 / 1000, yTop = top - settings.cover / 1000; + const legs = settings.shearLegs, pitch = (B - 2 * settings.cover / 1000) / (legs - 1); + for (const { name, x } of positions) for (let i = 0; i < legs / 2; i++) { + const xu = x + i * 2 * ds, xc = xu + ds; + const a = -B / 2 + settings.cover / 1000 + i * pitch, b = a + legs / 2 * pitch; + const bottom = envelope.inset(xu, (settings.cover + 12) / 1000); + const capBottom = envelope.inset(xc, (settings.cover + 112) / 1000); + add(name, 'H22', [[xu, yTop, a], [xu, bottom, a], [xu, bottom, b], [xu, yTop, b]]); + add(name, 'H22', [[xc, capBottom, a], [xc, yTop, a], [xc, yTop, b], [xc, capBottom, b]]); + } + const levels = sideRowLevels(model.levelBottom, model.endBottom, top); + const sideRows: { z: number; intervals: [number, number][] }[] = []; + for (let z = levels.zBottom; z <= top - settings.sideEndGap / 1000 + 1e-9; z += settings.sideSpacing / 1000) { + const intervals: [number, number][] = []; + const points = [0.127, ...envelope.xs.filter(x => x > 0.127 && x < L - 0.127), L - 0.127]; + for (let i = 1; i < points.length; i++) { + let a = points[i - 1], b = points[i]; + const za = envelope.inset(a, 0.127), zb = envelope.inset(b, 0.127); + if (Math.min(za, zb) > z) continue; + if (za > z) a += (b - a) * (za - z) / (za - zb); + else if (zb > z) b = a + (b - a) * (z - za) / (zb - za); + const previous = intervals.at(-1); + if (previous && Math.abs(previous[1] - a) < 1e-8) previous[1] = b; + else intervals.push([a, b]); + } + sideRows.push({ z, intervals }); + const name = z < levels.zBreak - 1e-9 ? 'C12' : 'C13', face = B / 2 - 0.127; + for (const [a, b] of intervals) { + add(name, 'H16', [[a, z, -face], [b, z, -face]]); + add(name, 'H16', [[a, z, face], [b, z, face]]); + // Short isolated intervals must not grow hooks through the adjacent haunch. + const leg = Math.min(SIDE_HOOK_LEG / 1000, (b - a) / 2), zh = z + NOMINAL_DIAMETER.H16 / 1000; + for (const [x, direction] of [[a, 1], [b, -1]]) add(name, 'H16', [[x + direction * leg, zh, -face], [x, zh, -face], [x, zh, face], [x + direction * leg, zh, face]]); + } + } + const hook = hookLength('main90', NOMINAL_DIAMETER.H16) / 1000; + const face = B / 2 - C14_INSET / 1000; + const addCross = (x: number, z: number) => { + if (z < envelope.inset(x, 0.1)) return; + const legsFit = z - hook >= envelope.inset(x, 0.1); + add('C14', 'H16', legsFit ? [[x, z - hook, -face], [x, z, -face], [x, z, face], [x, z - hook, face]] : [[x, z, -face], [x, z, face]]); + }; + for (const k of [1, 2]) for (let x = L / 2 % (settings.c14UpperSpacing / 1000); x <= L - 0.6; x += settings.c14UpperSpacing / 1000) if (x >= 0.6) addCross(x, top - 0.127 - k * settings.mainLayerGap / 1000 + 0.024); + for (const [i, row] of sideRows.entries()) if (i % 2 === 0) for (let x = 0.6; x <= L - 0.6; x += settings.c14LowerSpacing / 1000) if (row.intervals.some(([a, b]) => x >= a && x <= b)) addCross(x, row.z + 0.02); + const stairs = stairBars(L * 1000, B * 1000, top, material, 2 * settings.mainSpacing); + for (const [name, list] of [['C15', stairs.c15], ['C16', stairs.c16]] as const) for (const s of list) { + const a = s.x0 / 1000, b = s.x1 / 1000, n = s.n / 1000, h = s.leg / 1000; + add(name, 'H19', [[a, s.z - h, n], [a, s.z, n], [b, s.z, n], ...(s.legs === 'both' ? [[b, s.z - h, n] as CopingPoint] : [])]); + } + return bars; +} diff --git a/src/core/rebar/quantity.ts b/src/core/rebar/quantity.ts index 380137c..6f39711 100644 --- a/src/core/rebar/quantity.ts +++ b/src/core/rebar/quantity.ts @@ -60,25 +60,25 @@ export interface Quantity { ton: number; } -export function buildQuantity(rows: RebarRow[], material: Material): Quantity { +export function buildQuantity(rows: RebarRow[], material: Material, incomplete: readonly string[] = []): Quantity { const byGrade = new Map>(); const couplers: { name: string; count: number }[] = []; - const missing: string[] = []; + const missing: string[] = [...incomplete]; for (const r of rows) { if (r.name.includes('커플러')) { couplers.push({ name: r.name, count: r.count }); continue; } - if (!isBarSize(r.size)) continue; + if (!isBarSize(r.size) && r.diameterMm === undefined) continue; // A row with several lengths carries pieces; a row with one length is one piece (count bars of that length). const pieces = r.pieces ?? (r.length === null ? null : [{ length: r.length, count: r.count }]); if (!pieces) { missing.push(r.name); continue; } - const grade = fyOf(material, r.size); - const dMm = NOMINAL_DIAMETER[r.size]; + const grade = r.gradeMpa ?? fyOf(material, r.size as BarSize); + const dMm = r.diameterMm ?? NOMINAL_DIAMETER[r.size as BarSize]; const kgPerM = unitWeight(dMm); const pieceCount = pieces.reduce((s, p) => s + p.count, 0); const totalM = pieces.reduce((s, p) => s + (p.count * p.length) / 1000, 0); @@ -87,11 +87,12 @@ export function buildQuantity(rows: RebarRow[], material: Material): Quantity { length: pieces.length === 1 ? pieces[0].length : null, pieces, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape, }; const diameters = byGrade.get(grade) ?? new Map(); - const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 }; + const sameDiameter = [...diameters.values()].find(d => Math.abs(d.dMm - dMm) < 1e-8); + const d = sameDiameter ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 }; d.lines.push(line); d.totalM += totalM; d.kg += line.kg; - diameters.set(r.size, d); + diameters.set(d.size, d); byGrade.set(grade, diameters); } @@ -119,10 +120,11 @@ export function quantityRows(q: Quantity): (string | number)[][] { } out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]); } - out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]); + out.push([q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', '', '', '', '', '', '', '', round(q.kg)]); out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]); out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]); for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']); + for (const name of q.missing) out.push(['미계산 · 합계 미완료', name]); return out; } diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 274b199..2ea77dc 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -1,11 +1,14 @@ import type { PierModel } from '../geometry'; import { barLength, bearingBlockRebar, DEFAULT_BLOCK_REBAR, totals } from './bearing-block'; -import { DEFAULT_COPING_REBAR } from './coping-settings'; +import { copingDefaults } from './coping-settings'; import { DEFAULT_COLUMN_REBAR } from './column-settings'; import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings'; import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main'; import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping'; import { sideRowLevels } from '../coping-geom'; +import { CAST_PENDING } from './cast-in-place'; +import { castRebarLayout } from './cast-layout'; +import { multicolCopingBars } from './multicol-coping'; import { columnRebar, columnInputFor } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common'; @@ -15,7 +18,7 @@ export type Basis = 'input' | 'default' | 'calc' | 'assumed'; /** One line of a 철근표. length is null where the document gives no length yet. */ export interface RebarRow { - member: 'bearing' | 'coping' | 'column' | 'footing'; + member: 'bearing' | 'coping' | 'column' | 'footing' | 'pile'; name: string; size: string; count: number; @@ -31,6 +34,9 @@ export interface RebarRow { direction: string; shape: string; basis: Basis; + /** Explicit dedicated-section properties; absent for legacy rows. */ + diameterMm?: number; + gradeMpa?: number; } export interface RebarWarning { @@ -128,7 +134,7 @@ const MAIN_BARS = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8']; */ export function linkedSpacing(name: string, model: PierModel): Pick | undefined { const c = model.copingRebar; - const d = DEFAULT_COPING_REBAR; + const d = copingDefaults(model.columns.length); const b = model.blockRebar; const bd = DEFAULT_BLOCK_REBAR; const basis = (changed: boolean): Basis => (changed ? 'input' : 'default'); @@ -186,11 +192,12 @@ export function rebarSummary( model: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}, -): { rows: RebarRow[]; warnings: RebarWarning[] } { +): { rows: RebarRow[]; warnings: RebarWarning[]; incomplete: string[] } { const rows: RawRow[] = []; const warnings: RebarWarning[] = []; const mCoping = materialFor(common, byMember, 'coping'); const mColumn = materialFor(common, byMember, 'column'); + const cast = model.castInput ? castRebarLayout(model, mColumn) : undefined; const mFooting = materialFor(common, byMember, 'footing'); // The I02 note stays while the common values are the assumed ones, unless every fck-bearing member has its own fck. const ownFck = (['column', 'coping', 'footing'] as const).every((k) => byMember[k] !== undefined); @@ -217,6 +224,9 @@ export function rebarSummary( rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` }); // Coping (코핑철근.md §5–§8) + const colS = model.columns.map((c) => c.s * 1000); + const nCol = Math.max(1, colS.length); + if (nCol <= 1 || model.kink !== null) { const width = widthLayout(B, model.copingRebar); const perRow = width.count / 2; // cycle split, 12 per row // CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept. @@ -236,8 +246,6 @@ export function rebarSummary( const bottom = bottomPiece(mCoping); rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); - const colS = model.columns.map((c) => c.s * 1000); - const nCol = Math.max(1, colS.length); const geom = model.copingGeom; const shear = nCol > 1 ? shearBarsMulti(L, colS, geom) : { ...shearBars(L, undefined, geom), between: [] as number[] }; const uRange = (xs: number[]) => { @@ -262,7 +270,7 @@ export function rebarSummary( rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, pieces: loopPieces(shear.between), note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` }); } if (nCol > 1) { - warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' }); + warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.`, target: 'coping' }); } // Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2). const levels = sideRowLevels(model.levelBottom, model.endBottom, model.copingRefTop); @@ -282,21 +290,66 @@ export function rebarSummary( const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing); rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' }); rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` }); + } // Column (기둥철근.md · plan C4), P1 defaults - const col = columnRebar(columnInputFor(model), mColumn); - rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: col.mainP1 })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` }); - rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: col.mainP2 })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` }); + if (model.footings.length > 0) { + const columnRows: RawRow[] = []; + for (let columnIndex = 0; columnIndex < model.columns.length; columnIndex++) { + const first = rows.length; + const input = columnInputFor(model, undefined, columnIndex); + const col = columnRebar(input, mColumn); + rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: input.nDiv })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` }); + rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: input.nDiv })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` }); rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: `@${model.columnRebar.ctcCoupler}, P2는 +${model.columnRebar.dzCp2} mm 상향 엇갈림` }); const tieCouplerCount = col.p5Count + col.p4Count * 2; rows.push({ member: 'column', name: '띠철근 커플러', size: 'D19용 (L100 Ø40)', count: tieCouplerCount, length: 100, note: `단부 P5 1개(${col.p5Count}) · 기초안 P4 2개(${col.p4Count * 2}) 폐합용` }); rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: col.p3Length, note: `일반 구간 띠철근 @${model.columnRebar.ctcP3}, 겹침 ${col.lapP3} (추천안, Q-C6)` }); rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: col.p5Length, note: `단부 띠철근 @${model.columnRebar.ctcP5}, 커플러 1개` }); rows.push({ member: 'column', name: 'P4', size: 'H19', count: col.p4Count, length: col.p4SemicircleLength * 2, note: '확대기초 안, 반원 2개 (길이는 반원 2개 합) · B11 간섭 무시' }); - rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: '크로스타이, 8단 × 4' }); + rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: `크로스타이, ${col.p6Levels.length}단 × 4` }); - // Column bars and couplers repeat for each column (기둥철근 is per column; 다주형은 기둥마다). - for (const r of rows) if (r.member === 'column') r.count *= nCol; + columnRows.push(...rows.splice(first)); + } + for (const r of columnRows) { + const existing = rows.find(v => v.member === 'column' && v.name === r.name); + if (!existing) rows.push({ ...r, pieces: r.pieces ? [...r.pieces] : undefined }); + else { + if (existing.pieces || r.pieces || existing.length !== r.length) { + existing.pieces ??= [{ length: existing.length!, count: existing.count }]; + existing.pieces.push(...(r.pieces ?? [{ length: r.length!, count: r.count }])); + existing.length = null; + } + existing.count += r.count; + existing.note = '기둥별 실제 배치·길이 합산 · 마디별 수량은 각 기둥의 원주 등분 수'; + } + } + } else if (model.castInput) { + const layout = cast!; + const grouped = new Map(); + for (const bar of layout.bars) { + const key = `${bar.name}|${bar.diameterMm}|${bar.gradeMpa}`; + const group = grouped.get(key) ?? []; + group.push(bar); grouped.set(key, group); + } + for (const group of grouped.values()) { + const first = group[0]; + const byLength = new Map(); + for (const bar of group) { + const key = Math.round(bar.lengthMm * 1e6); + const piece = byLength.get(key) ?? { length: bar.lengthMm, count: 0 }; + piece.count++; byLength.set(key, piece); + } + const pieces = [...byLength.values()]; + rows.push({ member: first.member, name: first.name, size: first.kind === 'coupler' ? `커플러 Ø${first.diameterMm.toFixed(1)}` : `Ø${first.diameterMm}`, + count: group.length, length: pieces.length === 1 ? pieces[0].length : null, pieces, + diameterMm: first.diameterMm, gradeMpa: first.gradeMpa, note: first.note }); + } + layout.missing.forEach(message => warnings.push({ code: 'RB-CIP01', message, target: 'column' })); + } else { + warnings.push({ code: 'RB-CIP01', message: CAST_PENDING.join(' · ') + ' — 전용 입력을 저장할 수 있으며 3D·철근표·재료표 계산은 상세 확정 후 제공합니다.', target: 'column' }); + warnings.push({ code: 'RB-CIP02', message: '현장타설말뚝 철근 수량은 미계산입니다. 지원되는 다른 부재만 집계합니다.', target: 'pile' }); + } // Footing (확대기초철근 (1).md · plan C5). Only when the footing exists (not 현장타설말뚝). if (model.footings.length > 0) { // Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name; @@ -327,10 +380,32 @@ export function rebarSummary( } else { warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' }); } + if (nCol > 1 && model.kink === null) { + const grouped = new Map>(); + for (const b of multicolCopingBars(model, mCoping)) { + const group = grouped.get(b.name) ?? []; + group.push(b); grouped.set(b.name, group); + } + for (const [name, bars] of grouped) rows.push({ member: 'coping', name, size: bars[0].size, + count: bars.length, length: null, pieces: bars.map(b => ({ length: b.length, count: 1 })), + note: '다주형 전용 삽도 규격 · 실제 콘크리트 하면에 따른 조각별 중심선 길이. 번호·cycle별 이음 상세 확인 필요' }); + warnings.push({ code: 'RB-M02', target: 'coping', message: '다주형 전용 삽도 우선: C1~4 H25·C7 H29·C8 H25·전단 H22·수평/C14 H16. C5/C6 및 C11/C13 중복 번호·cycle별 이음 위치·분리 수평철근 갈고리는 상세 확인 필요. 해당 수량은 검토용입니다.' }); + warnings.push({ code: 'RB-M01', target: 'coping', message: '다주형 C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.' }); + warnings.push({ code: 'RB-M03', target: 'column', message: '다주형 단부는 삽도의 지름 이상·코핑 반높이 연장을 적용했습니다. P4/P5 번호는 기존 역할(단부 P5·기초내 P4)을 유지하며 도면 번호 대응 확인이 필요합니다.' }); + } // Table metadata per bar (구분·간격·실간격·위치·방향·형상·근거). The spacing rows take their values from the inputs (승인 2026-10-26). const withMeta = rows.map((r) => { + if (r.diameterMm !== undefined) return { ...r, spacing: '입력 구간별', actual: '중심선 계산', + position: r.name.startsWith('기둥·말뚝') ? '기둥~말뚝 연속' : r.member === 'pile' ? '지중 말뚝' : '지상 기둥·코핑', direction: '배근 경로', + shape: r.name.includes('커플러') ? '커플러' : r.name.includes('나선') ? '나선' : r.name.includes('크로스타이') ? '양끝 갈고리' : /P[35]/.test(r.name) ? '원형' : '직선', basis: 'calc' as const }; const linked = linkedSpacing(r.name, model); - return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}) }; + const multi = nCol > 1 && r.member === 'coping'; + return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}), ...(nCol > 1 && r.member === 'column' && r.name === 'P5' ? { position: '상·하 단부 · 코핑 반높이까지 (번호 확인 중)' } : {}), ...(multi ? { + shape: '다주형 조각별 경로 (검토용)', position: '다주형 전용 삽도 · 실제 하면', + ...(r.name === 'C9·C11 사이' ? { spacing: String(model.copingRebar.spacingS), actual: String(model.copingRebar.spacingS) } : {}) } : {}) }; }); - return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })) }; + return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })), incomplete: [ + ...(cast ? cast.missing : model.footings.length ? [] : CAST_PENDING), + ...(nCol > 1 ? ['다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'] : []), + ] }; } diff --git a/src/core/types.ts b/src/core/types.ts index b33e0bd..7746114 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,6 +1,8 @@ import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings'; import type { ColumnRebarSettings } from './rebar/column-settings'; import type { FootingRebarSettings } from './rebar/footing-settings'; +import type { CastInPlaceRebar } from './rebar/cast-in-place'; +import type { CastInPlaceShapeSettings } from './cast-in-place-shape'; export type PierType = 'T형' | '2주형' | '3주형'; export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝'; @@ -88,7 +90,11 @@ export interface PierInput { columnRebar?: Partial; /** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Absent = the 확대기초철근 (1).md defaults. */ footingRebar?: Partial; + /** 현장타설 전용 입력. 상세 규칙 확정 전에는 계산된 배근으로 취급하지 않는다. */ + castInPlaceRebar?: CastInPlaceRebar; strataRows?: StrataRow[]; + /** 현장타설 형상·암반·희생강관 입력. */ + castInPlaceShape?: CastInPlaceShapeSettings; } /** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */ diff --git a/src/core/validate.ts b/src/core/validate.ts index 030e0a1..d7107cb 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -3,6 +3,8 @@ import type { Issue, PierInput, PierType } from './types'; import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground'; import { integratedCheck } from './bearing-layout'; import { checkBlockRebar } from './rebar/block-check'; +import { castSettingsIssues } from './rebar/cast-in-place'; +import { castShape, castShapeIssues } from './cast-in-place-shape'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -16,6 +18,13 @@ export const NOT_IMPLEMENTED_RULES = [ /** Input-level checks (spec §10 E01, E02). Returns false when the pier must not be built. */ export function validateInput(input: PierInput, issues: Issue[]): boolean { let ok = true; + if (input.foundationType === '현장타설말뚝') { + for (const message of castShapeIssues(input.castInPlaceShape)) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message }); + const shape = castShape(input); + if (shape.casingOn && shape.casingTopEl <= shape.casingBottomEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '희생강관 상단 EL은 하단 EL보다 높아야 합니다.' }); + if (shape.weatheredRockEl < shape.bedrockEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '풍화암 EL이 기반암 EL보다 낮습니다. 암반 입력을 확인하세요.' }); + for (const message of castSettingsIssues(input.castInPlaceRebar, input.pileLengthMm, input.columnDiameterMm ?? 2000)) issues.push({ code: 'CIP01', level: 'W', pier: input.name, message }); + } if (input.columnCount !== EXPECTED_COLUMNS[input.type]) { issues.push({ code: 'E01', diff --git a/src/view/cast-rebar-svg.ts b/src/view/cast-rebar-svg.ts new file mode 100644 index 0000000..2406b3a --- /dev/null +++ b/src/view/cast-rebar-svg.ts @@ -0,0 +1,30 @@ +import type { PierModel } from '../core/geometry'; +import type { CastLayout } from '../core/rebar/cast-layout'; + +/** Front and section projections of the same centre lines used in the 3D scene. */ +export function castRebarSvg(model: PierModel, layout: CastLayout): string { + const bars = layout.bars.filter(b => b.columnIndex === 0); + if (!bars.length) return '

입력 오류를 확인하세요. 생성된 기둥·말뚝 철근이 없습니다.

'; + const pts = bars.flatMap(b => b.pts); + const bottom = pts.reduce((a, p) => Math.min(a, p[1]), Infinity); + const top = pts.reduce((a, p) => Math.max(a, p[1]), -Infinity); + const diameter = model.columnDiameterMm / 1000; + const scale = Math.min(180 / diameter, 480 / (top - bottom)); + const x = (v: number) => 130 + (v - model.columns[0].s) * scale; + const y = (v: number) => 35 + (top - v) * scale; + const colour = (kind: string) => kind === 'main' ? '#0f5c66' : kind === 'coupler' ? '#0b3f46' : '#6b3fa0'; + const front = bars.map(b => ``).join(''); + const sectionHeight = model.groundEl + Math.min(1, (model.columns[0].top - model.groundEl) / 2); + const sectionScale = 140 / diameter; + const section = bars.filter(b => b.kind === 'main' && b.pts[0][1] <= sectionHeight && b.pts.at(-1)![1] >= sectionHeight).map(b => { + const p = b.pts[0]; + return ``; + }).join(''); + return ` + 정면 · 실제 생성 경로 + + ${front} + 지반고 EL ${model.groundEl.toFixed(3)} + 기둥 주철근 단면${section} + 단부·저부 P6 미확정 사항은 알림 참조 · 길이 단위 mm`; +} diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 8f2927d..119349e 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -5,6 +5,8 @@ import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/c import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { sideRowLevels } from '../core/coping-geom'; import { columnRebar, columnInputFor } from '../core/rebar/column'; +import { castRebarLayout } from '../core/rebar/cast-layout'; +import { multicolCopingBars } from '../core/rebar/multicol-coping'; import { barDepths, barsAcross } from '../core/rebar/bearing-block'; import { blockFramePoint } from '../core/geometry'; import { INTEGRATED_BLOCK } from '../core/integrated-block'; @@ -65,6 +67,28 @@ const COLOR = { const mm = (v: number) => v / 1000; +/** Arc-length interpolation avoids a linear scan per vertex on long spiral paths. */ +class CastPolyline extends THREE.Curve { + private cumulative = [0]; + constructor(private points: [number, number, number][]) { + super(); + for (let i = 1; i < points.length; i++) this.cumulative.push(this.cumulative[i - 1] + Math.hypot(...points[i].map((v, j) => v - points[i - 1][j]))); + } + getPoint(t: number, target = new THREE.Vector3()): THREE.Vector3 { + const distance = Math.max(0, Math.min(1, t)) * this.getLength(); + let lo = 1, hi = this.points.length - 1; + while (lo < hi) { const mid = (lo + hi) >> 1; if (this.cumulative[mid] < distance) lo = mid + 1; else hi = mid; } + const fraction = (distance - this.cumulative[lo - 1]) / (this.cumulative[lo] - this.cumulative[lo - 1] || 1); + const a = this.points[lo - 1], b = this.points[lo]; + return target.set(a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction, a[2] + (b[2] - a[2]) * fraction); + } + getPointAt(t: number, target = new THREE.Vector3()): THREE.Vector3 { return this.getPoint(t, target); } + getTangentAt(t: number, target = new THREE.Vector3()): THREE.Vector3 { + return target.copy(this.getPoint(Math.min(1, t + 1e-5))).sub(this.getPoint(Math.max(0, t - 1e-5))).normalize(); + } + getLength(): number { return this.cumulative.at(-1)!; } +} + export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene { const mCoping = materialFor(common, byMember, 'coping'); const mColumn = materialFor(common, byMember, 'column'); @@ -140,7 +164,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, }; // Coping (straight coping only; a bent coping is not drawn in 3D yet). - if (straight) { + if (straight && m.columns.length > 1) { + for (const b of multicolCopingBars(m, mCoping)) { + if (/^C[1-8]$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping); + else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear); + } + } + if (straight && m.columns.length <= 1) { const w = widthLayout(B * 1000, m.copingRebar); const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2); const cover = 0.1; @@ -279,9 +309,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } // Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column. - const colInput = columnInputFor(m); - const col = columnRebar(colInput, mColumn); - const zBot = col.zBot; // mm + if (m.footings.length > 0) { const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => { const N = 36; for (let i = 0; i < N; i++) { @@ -290,7 +318,10 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, addBar(name, k, [sc + r * Math.cos(a0), y, r * Math.sin(a0)], [sc + r * Math.cos(a1), y, r * Math.sin(a1)], d, COLOR.tie); } }; - for (const c of m.columns) { + for (const [columnIndex, c] of m.columns.entries()) { + const colInput = columnInputFor(m, undefined, columnIndex); + const col = columnRebar(colInput, mColumn); + const zBot = col.zBot; const sc = c.s; const base = c.bottom; const rt = mm(col.tieRadius); @@ -376,6 +407,23 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } } + } + if (m.castInput) { + for (const bar of castRebarLayout(m, mColumn).bars) { + if (bar.pts.length === 2) { + addBar(bar.name, bar.k, bar.pts[0], bar.pts[1], bar.diameterMm, bar.kind === 'coupler' ? COLOR.couplerMain : COLOR.column); + } else { + records.push({ name: bar.name, k: bar.k, pts: bar.pts, dMm: bar.diameterMm }); + const path = new CastPolyline(bar.pts); + const geometry = new THREE.TubeGeometry(path, Math.min(20000, bar.pts.length), mm(bar.diameterMm) / 2, 6, false); + geometry.applyMatrix4(Ms[bar.k]); + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: rebarColor(COLOR.tie), roughness: 0.45, metalness: 0.1 })); + mesh.userData = { name: bar.name, roleColor: COLOR.tie, dMm: bar.diameterMm, member: 'column' }; + group.add(mesh); + const list = byName.get(bar.name) ?? []; list.push(mesh); byName.set(bar.name, list); + } + } + } // Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md. for (const f of m.footings) { const Lf = f.length; diff --git a/tests/cast-detail.test.ts b/tests/cast-detail.test.ts new file mode 100644 index 0000000..42aafca --- /dev/null +++ b/tests/cast-detail.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { castShape, castShapeIssues, castLowerZone } from '../src/core/cast-in-place-shape'; +import { castRebarDefaults } from '../src/core/rebar/cast-in-place'; +import { buildPier } from '../src/core/geometry'; +import { changedKeys } from '../src/core/changes'; +import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; +import { loadP1 } from './helpers'; + +describe('2026-11-05 cast-in-place drawing and user decisions', () => { + const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, footingBottomEl: 10, pileLengthMm: 24000 }; + it('uses ground level for the pile head and ground minus length for its tip', () => { + const model = buildPier(input); + expect(model.piles[0].top).toBe(30); + expect(model.piles[0].bottom).toBe(6); + expect(model.columns[0].bottom).toBe(30); + expect(model.footings).toEqual([]); + expect(model.derived.soilCover).toBe(0); + }); + it('uses the clarified positive rock offsets, 9000 mm beta and casing defaults', () => { + expect(castShape(input)).toMatchObject({ pileBottomEl: 6, betaLengthMm: 9000, betaEl: 21, weatheredRockEl: 11, bedrockEl: 7, casingOn: false, casingTopEl: 33, casingBottomEl: 7, clayBottomEl: undefined }); + }); + it('rejects a pile head above the coping instead of generating an inverted column', () => { + expect(() => buildPier({ ...input, groundEl: 100 })).toThrow('지반고'); + }); + it('rejects zero or negative cast-in-place pile length', () => { + for (const pileLengthMm of [0, -1]) expect(() => buildPier({ ...input, pileLengthMm })).toThrow('말뚝 길이'); + }); + it('keeps automatic values linked to geometry changes and manual overrides fixed', () => { + const auto = castShape({ ...input, groundEl: 40 }); + expect(auto.bedrockEl).toBe(17); + expect(auto.casingBottomEl).toBe(17); + expect(auto.casingTopEl).toBe(43); + const manual = castShape({ ...input, groundEl: 40, castInPlaceShape: { bedrockEl: -2, casingTopEl: 35, weatheredRockEl: 3 } }); + expect(manual.casingBottomEl).toBe(-2); + expect(manual.casingTopEl).toBe(35); + expect(manual.weatheredRockEl).toBe(3); + }); + it('requires independent clay EL and reports reversed lower zone instead of silently swapping it', () => { + expect(castLowerZone(input)).toBeNull(); + expect(castLowerZone({ ...input, castInPlaceShape: { clayBottomEl: 16 } })).toEqual({ bottomEl: 15, topEl: 18, valid: true }); + expect(castLowerZone({ ...input, castInPlaceShape: { clayBottomEl: 4 } })?.valid).toBe(false); + }); + it('inherits both round-column main layers, hoop spacing, cover convention and couplers', () => { + const s = castRebarDefaults(input); + expect(s.column).toMatchObject({ mainDiameterMm: 31.8, innerMainDiameterMm: 31.8, mainCount: 46, innerMainCount: 46, coverMm: 125, coverReference: 'main-center', tieSpacingMm: 300, endZoneSpacingMm: 120, tieKind: 'hoop' }); + expect(s.pile?.endCoverMm).toBe(250); + expect(s.connection).toEqual({ mode: 'coupler', couplerLengthMm: 150 }); + }); + it('preserves old explicit clear cover and independent edits', () => { + const s = castRebarDefaults({ ...input, columnRebar: { nDiv: 48, c: 140 }, castInPlaceRebar: { column: { coverMm: 100, mainCount: 44 }, pile: { endCoverMm: 300 } } }); + expect(s.column?.coverReference).toBe('clear'); + expect(s.column?.coverMm).toBe(100); + expect(s.column?.mainCount).toBe(44); + expect(s.pile?.coverMm).toBe(140); + expect(s.pile?.innerMainCount).toBe(48); + expect(s.pile?.endCoverMm).toBe(300); + }); + it('persists explicit shape values without turning auto defaults into fixed values', () => { + const next = { ...input, castInPlaceShape: { casingOn: true, clayBottomEl: 16, betaLengthMm: 10000 } }; + const p: ProjectFile = { schemaVersion: SCHEMA_VERSION, project: { name: '', bridge: '', client: '', author: '', crs: '', memo: '' }, source: { fileName: '', sheets: [] }, history: [], piers: [{ id: 'P1', input: next, defaults: input, draft: next }] }; + expect(parseProject(serializeProject(p))).toEqual(p); + expect(changedKeys(input, next)).toEqual(['castInPlaceShape']); + const invalid = JSON.parse(serializeProject(p)); + invalid.piers[0].draft.castInPlaceShape.betaLengthMm = 0; + expect(() => parseProject(JSON.stringify(invalid))).toThrow('현장타설'); + }); + it('permits negative elevations but rejects nonfinite elevations and invalid switches', () => { + expect(castShapeIssues({ bedrockEl: -20, casingBottomEl: -30 })).toEqual([]); + expect(castShapeIssues({ casingOn: 1 })).toHaveLength(1); + expect(castShapeIssues({ clayBottomEl: Infinity })).toHaveLength(1); + }); +}); diff --git a/tests/cast-in-place.test.ts b/tests/cast-in-place.test.ts new file mode 100644 index 0000000..bd2517c --- /dev/null +++ b/tests/cast-in-place.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { buildPierScene } from '../src/view/scene'; +import { buildRebar3D } from '../src/view/rebar3d'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { buildQuantity, quantityRows } from '../src/core/rebar/quantity'; +import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; +import { castSettingsIssues, type CastInPlaceRebar } from '../src/core/rebar/cast-in-place'; +import { castRebarLayout } from '../src/core/rebar/cast-layout'; +import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; +import { changedKeys } from '../src/core/changes'; +import { validateInput } from '../src/core/validate'; +import type { Issue, PierInput } from '../src/core/types'; +import { loadP1 } from './helpers'; + +const settings: CastInPlaceRebar = { column: { mainDiameterMm: 32, mainCount: 46, coverMm: 100, mainGradeMpa: 500 }, pile: { mainDiameterMm: 32, mainCount: 46, cageLengthMm: 12000, tieKind: 'spiral' }, connection: { mode: 'continuous' } }; +function project(input: PierInput): ProjectFile { + return { schemaVersion: SCHEMA_VERSION, project: { name: '', bridge: '', client: '', author: '', crs: '', memo: '' }, source: { fileName: '', sheets: [] }, history: [], piers: [{ id: 'one', input, defaults: { ...input, castInPlaceRebar: undefined }, draft: { ...input, castInPlaceRebar: settings } }] }; +} + +describe('cast-in-place independent rendering and incomplete quantities', () => { + for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]] as const) { + for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝'] as const) { + it(`${type} / ${foundationType}: concrete, rebar and summary remain usable`, () => { + const m = buildPier({ ...loadP1(), type, columnCount, foundationType }); + const concrete = buildPierScene(m); + expect(concrete.parts.coping.children.length).toBeGreaterThan(0); + const rebar = buildRebar3D(m); + const result = rebarSummary(m); + expect(rebar.byName.get('C1')!.length).toBeGreaterThan(0); + const q = buildQuantity(result.rows, DEFAULT_MATERIAL, result.incomplete); + if (foundationType === '현장타설말뚝') { + expect(m.footings).toHaveLength(0); + expect(m.piles).toHaveLength(columnCount); + m.columns.forEach((c, i) => expect(c.bottom).toBe(m.piles[i].top)); + expect(rebar.byName.get('기둥-P1')!.length).toBeGreaterThan(0); + expect(rebar.byName.get('말뚝-P1')!.length).toBeGreaterThan(0); + expect(rebar.byName.get('기둥-P3')!.length).toBeGreaterThan(0); + expect(rebar.byName.get('말뚝-P3')!.length).toBeGreaterThan(0); + expect(rebar.byName.has('B1')).toBe(false); + expect(result.rows.some(r => r.member === 'column')).toBe(true); + expect(result.rows.some(r => r.member === 'pile')).toBe(true); + expect(result.rows.some(r => r.member === 'footing')).toBe(false); + expect(q.missing).toEqual(result.incomplete); + expect(q.missing).toEqual(expect.arrayContaining(castRebarLayout(m).missing)); + expect(q.missing).toContain('말뚝 저부 P6 검토안 미적용'); + expect(quantityRows(q).filter(r => r[0] === '미계산 · 합계 미완료')).toHaveLength(q.missing.length); + expect(q.kg).toBeGreaterThan(0); + } else { + expect(rebar.byName.get('P1')!.length).toBeGreaterThan(0); + if (columnCount === 1) expect(result.incomplete).toEqual([]); + else expect(result.incomplete).toContain('다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'); + expect(q.missing).toEqual(result.incomplete); + } + rebar.group.traverse(o => { const mesh = o as import('three').Mesh; mesh.geometry?.dispose(); }); + }); + } + } +}); + +describe('cast-in-place storage and validation', () => { + it('round-trips dedicated settings in generated/default/draft inputs without merging legacy settings', () => { + const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, castInPlaceRebar: settings }; + const p = project(input); + expect(parseProject(serializeProject(p))).toEqual(p); + expect(changedKeys({ ...input, castInPlaceRebar: undefined }, input)).toContain('castInPlaceRebar'); + }); + it('legacy projects keep absent settings absent rather than inventing defaults', () => { + const p = project(loadP1()); + delete p.piers[0].draft; + expect(parseProject(serializeProject(p)).piers[0].input.castInPlaceRebar).toBeUndefined(); + }); + for (const bad of [null, [], { pile: [] }, { column: { mainCount: 2.5 } }, { pile: { coverMm: -1 } }, { connection: { mode: ['continuous'] } }, { column: { tieKind: 'unknown' } }, { column: { mainCount: '46' } }, { pile: { extra: 1 } }]) { + it(`rejects malformed settings ${JSON.stringify(bad)} in saved draft`, () => { + const p = project(loadP1()); + p.piers[0].draft!.castInPlaceRebar = bad as CastInPlaceRebar; + expect(() => parseProject(serializeProject(p))).toThrow('현장타설'); + }); + } + it('geometry length changes produce a warning without destroying concrete or preventing reopening', () => { + const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, pileLengthMm: 10000, castInPlaceRebar: settings }; + const issues: Issue[] = []; + expect(validateInput(input, issues)).toBe(true); + expect(issues.some(i => i.code === 'CIP01')).toBe(true); + expect(() => parseProject(serializeProject(project(input)))).not.toThrow(); + }); + it('has no numeric defaults and checks cage length and cover geometry', () => { + expect(castSettingsIssues({})).toEqual([]); + expect(castSettingsIssues({ pile: { cageLengthMm: 15000 } }, 10000)).toHaveLength(1); + expect(castSettingsIssues({ column: { coverMm: 1000 } }, 10000, 2000)).toHaveLength(1); + expect(castSettingsIssues({ pile: { mainCount: Infinity } })).toHaveLength(1); + }); +}); diff --git a/tests/cast-layout.test.ts b/tests/cast-layout.test.ts new file mode 100644 index 0000000..fa2abe0 --- /dev/null +++ b/tests/cast-layout.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { castRebarLayout } from '../src/core/rebar/cast-layout'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { buildQuantity, unitWeight } from '../src/core/rebar/quantity'; +import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; +import { castRebarSvg } from '../src/view/cast-rebar-svg'; +import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; +import { loadP1 } from './helpers'; + +const base = () => ({ ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 24000 }); +const layout = (changes: Partial> = {}) => castRebarLayout(buildPier({ ...base(), ...changes })); + +describe('dedicated cast-in-place physical reinforcement', () => { + it('generates both main layers on both sides of ground and one interface coupler per line', () => { + const result = layout(); + for (const part of ['기둥', '말뚝']) for (const mark of ['P1', 'P2', 'P3']) expect(result.bars.some(b=>b.name===`${part}-${mark}`)).toBe(true); + const joints = result.bars.filter(b=>b.name.startsWith('접합-')); + expect(joints).toHaveLength(92); + expect(joints.filter(b=>b.name==='접합-P1 커플러').every(b=>Math.abs((b.pts[0][1]+b.pts[1][1])/2-30)<1e-8)).toBe(true); + expect(joints.filter(b=>b.name==='접합-P2 커플러').every(b=>Math.abs((b.pts[0][1]+b.pts[1][1])/2-30.15)<1e-8)).toBe(true); + const bottoms = result.bars.filter(b=>b.name==='말뚝-P1').flatMap(b=>b.pts.map(p=>p[1])); + expect(Math.min(...bottoms)).toBeCloseTo(6 + (250 + 31.8/2)/1000); + expect(result.bars.some(b=>b.name.includes('P4')||b.name==='말뚝-P6')).toBe(false); + }); + it('gives the same centre-line endpoints to both main pieces at every joint', () => { + const result=layout(); + for(const joint of result.bars.filter(b=>b.kind==='coupler' && b.name.startsWith('접합-'))) { + const centre=joint.pts[0].map((v,i)=>(v+joint.pts[1][i])/2); + const endpoints=result.bars.filter(b=>b.kind==='main').flatMap(b=>[b.pts[0],b.pts.at(-1)!]); + expect(endpoints.filter(p=>p.every((v,i)=>Math.abs(v-centre[i])<1e-8))).toHaveLength(2); + } + }); + it('keeps every generated ordinary hoop surface inside the section and above the tip', () => { + const centre = buildPier(base()).columns[0].s; + for (const bar of layout().bars.filter(b=>b.kind!=='coupler')) for(const point of bar.pts) { + if(bar.kind==='main' || bar.kind==='tie') expect(Math.hypot(point[0]-centre,point[2])+bar.diameterMm/2000).toBeLessThanOrEqual(1+1e-7); + expect(point[1]-bar.diameterMm/2000).toBeGreaterThanOrEqual(6.25-1e-7); + } + }); + it('uses each section diameter, grade and count without silently equating mismatched joints', () => { + const result=layout({castInPlaceRebar:{column:{mainCount:44,mainDiameterMm:35.8,mainGradeMpa:400},pile:{mainCount:48,mainGradeMpa:500}}}); + expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false); + expect(result.missing.some(m=>m.includes('불일치'))).toBe(true); + expect(result.bars.filter(b=>b.name==='기둥-P1').every(b=>b.diameterMm===35.8 && b.gradeMpa===400)).toBe(true); + }); + it('converts clear cover to the main centre correctly and preserves two different cover conventions', () => { + const centre=layout({castInPlaceRebar:{column:{coverMm:125,coverReference:'main-center'}}}); + const clear=layout({castInPlaceRebar:{column:{coverMm:125,coverReference:'clear'}}}); + const x=buildPier(base()).columns[0].s; + expect(centre.bars.find(b=>b.name==='기둥-P1')!.pts[0][0]-x).toBeCloseTo(.875); + expect(clear.bars.find(b=>b.name==='기둥-P1')!.pts[0][0]-x).toBeCloseTo((1000-125-19.1-15.9)/1000); + }); + it('places dense hoops in the manual upper column and beta/clay lower pile zones', () => { + const result=layout({castInPlaceShape:{clayBottomEl:16},castInPlaceRebar:{column:{endZoneLengthMm:2000}}}); + for (const part of ['기둥','말뚝']) expect(result.bars.some(b=>b.name===`${part}-P5`)).toBe(true); + for(const b of result.bars.filter(b=>b.name==='말뚝-P5')) expect(b.pts[0][1]).toBeGreaterThan(15); + for(const b of result.bars.filter(b=>b.name==='말뚝-P5')) expect(b.pts[0][1]).toBeLessThan(18); + expect(result.bars.filter(b=>b.name==='말뚝-P5 띠철근 커플러')).toHaveLength(result.bars.filter(b=>b.name==='말뚝-P5').length); + }); + it('reports missing/reversed/out-of-range end zones while keeping ordinary rebar visible', () => { + for(const settings of [{},{castInPlaceShape:{clayBottomEl:4}},{castInPlaceRebar:{column:{endZoneLengthMm:99999}}}]) { + const result=layout(settings); + expect(result.bars.some(b=>b.name==='기둥-P1')).toBe(true); + expect(result.missing.some(m=>m.includes('경계')||m.includes('단부구간'))).toBe(true); + } + }); + it('uses a real helix with rise and analytical length when spiral is selected', () => { + const result=layout({castInPlaceRebar:{pile:{tieKind:'spiral',tieSpacingMm:250}}}); + const helix=result.bars.find(b=>b.name==='말뚝-P3 나선')!; + expect(helix.pts.at(-1)![1]).toBeGreaterThan(helix.pts[0][1]); + expect(helix.lengthMm).toBeGreaterThan((helix.pts.at(-1)![1]-helix.pts[0][1])*1000); + expect(result.missing.some(m=>m.includes('나선철근'))).toBe(true); + }); + it('merges continuous mains into one physical bar across the joint', () => { + const result=layout({castInPlaceRebar:{connection:{mode:'continuous'}}}); + expect(result.bars.filter(b=>b.name==='기둥·말뚝-P1')).toHaveLength(46); + expect(result.bars.filter(b=>b.name==='기둥·말뚝-P2')).toHaveLength(46); + expect(result.bars.some(b=>b.kind==='coupler')).toBe(false); + expect(result.bars.find(b=>b.name==='기둥·말뚝-P1')!.pts[0][1]).toBeLessThan(30); + }); + it('does not claim a missing lap or mismatched joint is connected', () => { + const result=layout({castInPlaceRebar:{connection:{mode:'lap'}}}); + expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false); + expect(result.missing.some(m=>m.includes('겹침'))).toBe(true); + }); + it('excludes an invalid pile cage rather than silently clipping it', () => { + const result=layout({castInPlaceRebar:{pile:{cageLengthMm:24000}}}); + expect(result.bars.some(b=>b.member==='pile')).toBe(false); + expect(result.missing.some(m=>m.includes('선단 피복'))).toBe(true); + }); + it('rejects coupler lengths that exceed the joint space without crashing the drawing', () => { + const result=layout({castInPlaceRebar:{connection:{couplerLengthMm:5000}}}); + expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false); + expect(result.missing.some(m=>m.includes('커플러 길이'))).toBe(true); + }); + it('does not draw a multi-turn hoop when the lap exceeds the circumference', () => { + const result=layout({castInPlaceRebar:{pile:{concreteStrengthMpa:0.001}}}); + expect(result.missing.some(m=>m.includes('겹침길이가 원주 이상'))).toBe(true); + expect(result.bars.some(b=>b.name==='말뚝-P1')).toBe(true); + }); + it('repeats physical pieces per actual column without multiplying piece counts again', () => { + const one=layout(), three=layout({type:'3주형',columnCount:3}); + expect(three.bars.filter(b=>b.kind==='main')).toHaveLength(one.bars.filter(b=>b.kind==='main').length*3); + expect(new Set(three.bars.map(b=>b.columnIndex)).size).toBe(3); + }); + it('uses the same physical pieces, diameters and grades for table length and mass', () => { + const input={...base(),castInPlaceRebar:{column:{mainGradeMpa:400,mainDiameterMm:35.8}}}; + const model=buildPier(input), result=castRebarLayout(model), summary=rebarSummary(model); + const q=buildQuantity(summary.rows.filter(r=>r.diameterMm!==undefined),DEFAULT_MATERIAL,summary.incomplete); + const steel=result.bars.filter(b=>b.kind!=='coupler'); + expect(q.groups.flatMap(g=>g.diameters.flatMap(d=>d.lines)).reduce((s,l)=>s+l.pieceCount,0)).toBe(steel.length); + const mass=steel.reduce((s,b)=>s+b.lengthMm/1000*unitWeight(b.diameterMm),0); + expect(q.kg).toBeCloseTo(mass,8); + expect(q.groups.find(g=>g.grade===400)!.diameters.some(d=>d.dMm===35.8)).toBe(true); + expect(q.couplers.reduce((s,c)=>s+c.count,0)).toBe(result.bars.filter(b=>b.kind==='coupler').length); + }); + it('groups identical hoops as one length and merges nominal/equivalent diameter subtotals', () => { + const summary=rebarSummary(buildPier(base())); + const hoop=summary.rows.find(r=>r.name==='말뚝-P3')!; + expect(hoop.pieces).toHaveLength(1); + expect(hoop.pieces![0].count).toBe(hoop.count); + expect(hoop.length).toBeGreaterThan(0); + const q=buildQuantity(summary.rows,DEFAULT_MATERIAL,summary.incomplete); + expect(q.groups.find(g=>g.grade===400)!.diameters.filter(d=>Math.abs(d.dMm-19.1)<1e-8)).toHaveLength(1); + }); + it('restores generated bar paths and quantities from the real project schema', () => { + const input={...base(),castInPlaceRebar:{column:{mainCount:48},pile:{mainCount:48},connection:{mode:'coupler' as const,couplerLengthMm:180}}}; + const file:ProjectFile={schemaVersion:SCHEMA_VERSION,project:{name:'검증',bridge:'',client:'',author:'',crs:'',memo:''},source:{fileName:'',sheets:[]},history:[],piers:[{id:'P1',input,defaults:input,draft:input}]}; + const restored=parseProject(serializeProject(file)).piers[0].input; + expect(castRebarLayout(buildPier(restored))).toEqual(castRebarLayout(buildPier(input))); + expect(castRebarSvg(buildPier(restored),castRebarLayout(buildPier(restored)))).toContain(' { + for (const [type, columnCount] of [['2주형', 2], ['3주형', 3]] as const) { + for (const foundationType of ['말뚝기초', '현장타설말뚝'] as const) { + it(`${type} ${foundationType}: every coping path stays inside the concrete lower boundary`, () => { + const model = buildPier({ ...loadP1(), type, columnCount, foundationType, groundEl: 30, pileLengthMm: 24000 }); + const bars = multicolCopingBars(model); + expect(bars.length).toBeGreaterThan(100); + const polygon = model.copingParts[0].profile; + // Independent intersection against the concrete polygon; include all haunch vertices. + const lower = (x: number) => Math.min(...polygon.flatMap((a, i) => { + const b = polygon[(i + 1) % polygon.length]; + return Math.abs(a[0] - b[0]) > 1e-9 && x >= Math.min(a[0], b[0]) - 1e-9 && x <= Math.max(a[0], b[0]) + 1e-9 + ? [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])] : []; + })); + for (const bar of bars.filter(b => !['C15', 'C16'].includes(b.name))) for (let i = 1; i < bar.pts.length; i++) { + const a = bar.pts[i - 1], b = bar.pts[i]; + const ts = [...Array.from({ length: 21 }, (_, j) => j / 20), ...polygon.map(p => (p[0] - a[0]) / (b[0] - a[0])).filter(t => Number.isFinite(t) && t >= 0 && t <= 1)]; + for (const t of ts) { + const x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t; + expect(y - lower(x), `${bar.name} at ${x}`).toBeGreaterThanOrEqual(0.099); + } + } + const summary = rebarSummary(model); + for (const row of summary.rows.filter(r => r.member === 'coping')) { + const pieces = bars.filter(b => b.name === row.name); + expect(row.count).toBe(pieces.length); + expect(row.pieces!.reduce((s, p) => s + p.count * p.length, 0)).toBeCloseTo(pieces.reduce((s, p) => s + p.length, 0), 6); + } + const scene = buildRebar3D(model); + for (const name of ['C7', 'C8', 'C10', 'C12', 'C14']) { + const expected = bars.filter(b => b.name === name); + expect(scene.records.filter(r => r.name === name).every(r => r.dMm === expected[0].diameterMm)).toBe(true); + expect(scene.records.some(r => r.name === name)).toBe(true); + } + scene.group.traverse(o => { const mesh = o as import('three').Mesh; mesh.geometry?.dispose(); const m = mesh.material; if (Array.isArray(m)) m.forEach(v => v.dispose()); else m?.dispose(); }); + }, 15000); + } + it(`${type}: physical column pieces and local confinement height agree`, () => { + const model = buildPier({ ...loadP1(), type, columnCount }); + const rows = rebarSummary(model).rows; + for (const name of ['P1', 'P2']) { + const row = rows.find(r => r.name === name)!; + expect(row.pieces!.reduce((s, p) => s + p.count, 0)).toBe(row.count); + const key = name === 'P1' ? 'p1Pieces' : 'p2Pieces'; + const total = model.columns.reduce((s, _, i) => { + const input = columnInputFor(model, undefined, i); + return s + input.nDiv * columnRebar(input)[key].reduce((a, b) => a + b, 0); + }, 0); + expect(row.pieces!.reduce((s, p) => s + p.count * p.length, 0)).toBeCloseTo(total, 6); + } + for (const [i, column] of model.columns.entries()) { + const input = columnInputFor(model, undefined, i), detail = columnRebar(input); + const segment = model.segments.find(s => column.s >= s.s0 && column.s <= s.s1)!; + expect(column.bottom + detail.zTopT / 1000).toBeCloseTo((column.top + segment.top) / 2, 3); + expect(detail.p5.filter(z => z > input.Hc - input.D).length).toBeGreaterThan(10); + expect(Math.max(...detail.p5)).toBeLessThanOrEqual(detail.zTopT); + expect(detail.p3.every(z => z > detail.lE && z < input.Hc - detail.lE)).toBe(true); + } + }); + } + it('short columns merge dense ends without duplicate or out-of-range rings', () => { + const detail = columnRebar({ ...columnInputFor(buildPier({ ...loadP1(), type: '2주형', columnCount: 2 })), Hc: 1000 }); + expect(detail.p3).toEqual([]); + expect(new Set(detail.p5).size).toBe(detail.p5.length); + expect(detail.p5.every(z => z >= 0 && z <= detail.zTopT)).toBe(true); + }); + it('cast upper dense zone includes the coping half-height and clips at ground', () => { + const model = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, foundationType: '현장타설말뚝', groundEl: 40, + pileLengthMm: 24000, castInPlaceRebar: { column: { endZoneLengthMm: 3000 } } }); + const layout = castRebarLayout(model); + for (let i = 0; i < 3; i++) { + const ties = layout.bars.filter(b => b.columnIndex === i && b.name === '기둥-P5'); + expect(ties.length).toBeGreaterThan(10); + expect(ties.some(b => b.pts[0][1] > model.columns[i].top)).toBe(true); + expect(ties.every(b => b.pts[0][1] >= 40)).toBe(true); + const pileTies = layout.bars.filter(b => b.columnIndex === i && b.name === '말뚝-P5'); + expect(pileTies.some(b => b.pts[0][1] >= model.columns[i].top - 3 && b.pts[0][1] < 40)).toBe(true); + } + }); + it('drawing defaults yield to saved overrides and leave the T-shaped defaults unchanged', () => { + const multi = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 }); + expect(multi.copingRebar).toMatchObject({ spacingS: 300, sideSpacing: 200, c14UpperSpacing: 600 }); + const edited = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingRebar: { spacingS: 275 } }); + expect(edited.copingRebar.spacingS).toBe(275); + expect(buildPier(loadP1()).copingRebar.spacingS).toBe(250); + expect(() => multicolCopingBars({ ...multi, copingRebar: { ...multi.copingRebar, sideSpacing: 0 } })).toThrow('양수'); + }); +}); diff --git a/tests/rebar-multi.test.ts b/tests/rebar-multi.test.ts index 3769e91..68c44da 100644 --- a/tests/rebar-multi.test.ts +++ b/tests/rebar-multi.test.ts @@ -10,18 +10,18 @@ const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLe const count = (rows: ReturnType['rows'], name: string) => rows.find((r) => r.name === name)!.count; describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => { - it('2주형: C10 2 × 9 = 18; C9 and C11 3 each outside; 18 between (추천안, Q-K2)', () => { + it('2주형: H22 @300, six physical pieces per position', () => { const rows = rebarSummary(two).rows; - expect(count(rows, 'C10')).toBe(18); - expect(count(rows, 'C9')).toBe(3); - expect(count(rows, 'C11')).toBe(3); - expect(count(rows, 'C9·C11 사이')).toBe(18); + expect(count(rows, 'C10')).toBe(18 * 6); + expect(count(rows, 'C9')).toBe(3 * 6); + expect(count(rows, 'C11')).toBe(3 * 6); + expect(count(rows, 'C9·C11 사이')).toBe(15 * 6); }); - it('3주형: C10 3 × 9 = 27; two gaps of 3 between', () => { + it('3주형: C10 27 positions and two gaps of three, each with six physical pieces', () => { const rows = rebarSummary(three).rows; - expect(count(rows, 'C10')).toBe(27); - expect(count(rows, 'C9·C11 사이')).toBe(6); + expect(count(rows, 'C10')).toBe(27 * 6); + expect(count(rows, 'C9·C11 사이')).toBe(6 * 6); }); it('column bars repeat per column: P1 230 × 2 and 230 × 3', () => { diff --git a/tests/rebar-pieces.test.ts b/tests/rebar-pieces.test.ts index 352631e..9c16f45 100644 --- a/tests/rebar-pieces.test.ts +++ b/tests/rebar-pieces.test.ts @@ -33,9 +33,11 @@ describe('조각 길이 (승인 2026-10-23): 여러 길이를 가진 행', () => expect(total(row('C12'))).toBeGreaterThan(0); }); - it('P1 and P2 pieces: each splice piece counts all the bars (230)', () => { - expect(row('P1').pieces!.every((p) => p.count === 230)).toBe(true); - expect(row('P2').pieces!.every((p) => p.count === 230)).toBe(true); + it('P1 and P2 pieces: each level has 46 bars, giving 230 physical pieces in all', () => { + expect(row('P1').pieces!.every((p) => p.count === 46)).toBe(true); + expect(row('P2').pieces!.every((p) => p.count === 46)).toBe(true); + expect(bars(row('P1'))).toBe(row('P1').count); + expect(bars(row('P2'))).toBe(row('P2').count); expect(row('P1').pieces!.length).toBeGreaterThan(1); }); diff --git a/tests/validate-and-foundation.test.ts b/tests/validate-and-foundation.test.ts index 77483fd..63fcc71 100644 --- a/tests/validate-and-foundation.test.ts +++ b/tests/validate-and-foundation.test.ts @@ -82,29 +82,56 @@ describe('foundation types', () => { expect(m.piles[0].diameter).toBeCloseTo(0.6096, 6); }); - it('2주형: underside between columns drops 300 below Level, T형 has no such point (Q3 추천안)', () => { + it('2주형: underside between columns rises 300 above Level; column tops and T형 stay at Level', () => { const t = buildPier(loadP1()); - const dip = t.levelBottom - 0.3; - expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(false); + const raised = t.levelBottom + 0.3; + expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(false); const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 }); - expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(true); + expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(true); const outlineLow = Math.min(...two.copingParts[0].profile.map(([, z]) => z)); - expect(outlineLow).toBeCloseTo(dip, 9); + expect(outlineLow).toBeCloseTo(two.levelBottom, 9); + expect(two.columns.every((c) => c.top === two.levelBottom)).toBe(true); }); it('3주형 builds with two gaps of haunches', () => { const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3 }); - const dips = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom - 0.3)) < 1e-9); - expect(dips).toHaveLength(4); + const raised = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom + 0.3)) < 1e-9); + expect(raised).toHaveLength(4); }); - it('현장타설말뚝: no footing, one pile of column diameter per column, tip = bottom EL − length', () => { - const m = buildPier({ ...loadP1(), foundationType: '현장타설말뚝' }); + for (const type of ['2주형', '3주형'] as const) { + for (const dims of [{}, { copingLengthMm: 15000, copingH1Mm: 1200, copingH2Mm: 1000, levelWidthMm: 2400 }]) { + it(`${type}: raised spans and 300 × 300 haunches follow every column, dimensions ${JSON.stringify(dims)}`, () => { + const m = buildPier({ ...loadP1(), ...dims, type, columnCount: type === '2주형' ? 2 : 3 }); + const profile = m.copingParts[0].profile; + const half = m.copingGeom.levelWidth / 2000; + const has = (x: number, z: number) => profile.some(([s, el]) => Math.abs(s - x) < 1e-9 && Math.abs(el - z) < 1e-9); + for (const c of m.columns) { + expect(has(c.s - half, m.levelBottom)).toBe(true); + expect(has(c.s + half, m.levelBottom)).toBe(true); + expect(c.top).toBe(m.levelBottom); + } + for (let i = 0; i < m.columns.length - 1; i++) { + const left = m.columns[i].s + half; + const right = m.columns[i + 1].s - half; + expect(has(left + 0.3, m.levelBottom + 0.3)).toBe(true); + expect(has(right - 0.3, m.levelBottom + 0.3)).toBe(true); + } + expect(Math.min(...profile.map(([, z]) => z))).toBeCloseTo(m.levelBottom, 9); + }); + } + } + + it('현장타설말뚝: no footing, one pile of column diameter per column, tip = ground EL − length', () => { + const input = { ...loadP1(), foundationType: '현장타설말뚝' as const }; + const m = buildPier(input); expect(m.footings).toHaveLength(0); expect(m.piles).toHaveLength(1); expect(m.piles[0].diameter).toBeCloseTo(2.0, 6); - expect(m.piles[0].bottom).toBeCloseTo(26.208 - 24, 6); + expect(m.piles[0].top).toBe(input.groundEl); + expect(m.columns[0].bottom).toBe(input.groundEl); + expect(m.piles[0].bottom).toBeCloseTo(input.groundEl - input.pileLengthMm / 1000, 6); }); }); diff --git a/verify/dump/multicol-review.dump.ts b/verify/dump/multicol-review.dump.ts new file mode 100644 index 0000000..ea59624 --- /dev/null +++ b/verify/dump/multicol-review.dump.ts @@ -0,0 +1,75 @@ +/** Read-only diagnostic; a passing dump means the evidence was written, not design compliance. */ +import { it, expect } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { loadP1 } from '../../tests/helpers'; +import { buildPier } from '../../src/core/geometry'; +import { buildRebar3D, type BarRecord } from '../../src/view/rebar3d'; +import { castRebarLayout } from '../../src/core/rebar/cast-layout'; +import { columnInputFor, columnRebar } from '../../src/core/rebar/column'; +import { rebarSummary } from '../../src/core/rebar/summary'; +import { buildQuantity } from '../../src/core/rebar/quantity'; +import { DEFAULT_MATERIAL } from '../../src/core/rebar/common'; + +it('records multi-column placement, concrete-envelope and quantity evidence', () => { + const out=resolve('.verify/multicol-fixed-20261106'); mkdirSync(out,{recursive:true}); + const cases: unknown[]=[]; + for(const [type,columnCount] of [['2주형',2],['3주형',3]] as const) { + for(const mode of ['footing','cast-default','cast-zones'] as const) { + const input={...loadP1(),type,columnCount,foundationType:mode==='footing'?'말뚝기초' as const:'현장타설말뚝' as const, + ...(mode!=='footing'?{groundEl:30,pileLengthMm:24000}:{}), + ...(mode==='cast-zones'?{castInPlaceRebar:{column:{endZoneLengthMm:3000}},castInPlaceShape:{clayBottomEl:16}}:{})}; + const model=buildPier(input), rb=buildRebar3D(model), summary=rebarSummary(model); + const polygon=model.copingParts[0].profile; + const underside=(x:number)=>{ + const heights:number[]=[]; + polygon.forEach((p,i)=>{ + const q=polygon[(i+1)%polygon.length]; + if(Math.abs(q[0]-p[0])<1e-9)return; + if(x>=Math.min(p[0],q[0])-1e-9 && x<=Math.max(p[0],q[0])+1e-9)heights.push(p[1]+(q[1]-p[1])*(x-p[0])/(q[0]-p[0])); + }); + return Math.min(...heights); + }; + const envelope=(filter:(r:BarRecord)=>boolean)=>{ + let minimum=Infinity,worst:unknown,below=0,samples=0; + for(const record of rb.records.filter(filter)) { + for(let i=1;ij/20),...polygon.map(p=>(p[0]-a[0])/(b[0]-a[0])).filter(t=>Number.isFinite(t)&&t>=0&&t<=1)]; + for(const fraction of fractions) { + const x=a[0]+(b[0]-a[0])*fraction,y=a[1]+(b[1]-a[1])*fraction; + const lower=underside(x); if(!Number.isFinite(lower))continue; + const gap=(y-lower)*1000; samples++;if(gap<0)below++; + if(gap{const x=(model.columns[i].s+c.s)/2;return{xMm:x*1000,concreteUndersideEl:underside(x)};}); + const details=mode==='footing'?columnRebar(columnInputFor(model)):undefined; + const cast=castRebarLayout(model); + const perColumn=model.columns.map((c,i)=>{ + const bars=cast.bars.filter(b=>b.columnIndex===i); + const counts:Record={};bars.forEach(b=>counts[b.name]=(counts[b.name]??0)+1); + const seg=model.segments.find(s=>c.s>=s.s0&&c.s<=s.s1)!; + return{index:i+1,centreMm:c.s*1000,bottomEl:c.bottom,levelEl:c.top,localCopingTopEl:seg.top, + intendedTieTopEl:(c.top+seg.top)/2,legacyTieTopEl:details?c.bottom+columnRebar(columnInputFor(model,undefined,i)).zTopT/1000:null, + castBars:bars.length,castCounts:counts,castMainLengthM:bars.filter(b=>b.kind==='main').reduce((s,b)=>s+b.lengthMm/1000,0)}; + }); + const q=buildQuantity(summary.rows,DEFAULT_MATERIAL,summary.incomplete); + const mainRows=summary.rows.filter(r=>r.name==='P1'||r.name==='P2').map(r=>({name:r.name,rowCount:r.count,pieceCount:r.pieces?.reduce((s,p)=>s+p.count,0)})); + cases.push({type,mode,inputBasis:'public/samples/운곡교P1입력.xlsx, geometry type substituted; cast ground30m and length24000mm', + copingLengthMm:model.copingL*1000,diameterMm:model.columnDiameterMm,levelEl:model.levelBottom,between,perColumn, + copingProfile:polygon,c7Paths:rb.records.filter(r=>r.name==='C7').slice(0,2).map(r=>r.pts), + C7C8:envelope(r=>r.name==='C7'||r.name==='C8'),shear:envelope(r=>/^C(9|10|11)$/.test(r.name)||r.name==='C9·C11 사이'), + legacyLowerDenseLengthMm:details?.lE,workbookOnePointFiveDMm:1.5*model.columnDiameterMm, + legacyTopDenseHoops:details?.p5.filter(z=>z>=columnInputFor(model).Hc-1.5*model.columnDiameterMm).length, + mainRows,quantityKg:q.kg,incomplete:summary.incomplete}); + rb.group.traverse(o=>{const mesh=o as import('three').Mesh;mesh.geometry?.dispose();const m=mesh.material;if(Array.isArray(m))m.forEach(x=>x.dispose());else m?.dispose();}); + } + } + writeFileSync(resolve(out,'geometry-diagnostic.json'),JSON.stringify(cases,null,2)); + console.log(JSON.stringify(cases.map((c:any)=>({type:c.type,mode:c.mode,C7C8min:c.C7C8.minimumCentreAboveUndersideMm,shearMin:c.shear.minimumCentreAboveUndersideMm,denseMm:c.legacyLowerDenseLengthMm,perColumn:c.perColumn.map((p:any)=>p.castBars),mainRows:c.mainRows})))); + expect(cases).toHaveLength(6); +},30000);