feat(rebar): 현장타설 말뚝 · 다주형 코핑 형상과 철근 계산
- 현장타설 기둥·말뚝 형상(cast-in-place-shape)과 주철근·횡철근·커플러 계산(rebar/cast-in-place, cast-layout) - 다주형 코핑 철근(multicol-coping), 기둥 높이 · 양단부 연장 - 철근표 요약 · 재료 수량 · 3D 뷰 연결과 삽도(cast-rebar-svg) - 검사 기준 갱신과 회귀 테스트, 검증 스크립트 보관 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3922b330d5
commit
2e0bfb6fc8
29 files changed
+1750
-71
No files matched your search
@@ -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(); }
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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; });
|
||||
@@ -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<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape'>) {
|
||||
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<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape' | 'columnDiameterMm'>) {
|
||||
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 };
|
||||
}
|
||||
+3
-1
@@ -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<EditableKey, string> = {
|
||||
columnRebar: '기둥 배근 입력',
|
||||
footingRebar: '확대기초 배근 입력',
|
||||
strataRows: '지층 행',
|
||||
castInPlaceRebar: '현장타설 기둥·말뚝 배근 입력',
|
||||
castInPlaceShape: '현장타설 암반·고정점·희생강관 입력',
|
||||
};
|
||||
|
||||
/** Keys whose values differ between two inputs. Arrays are compared by value. */
|
||||
|
||||
+16
-10
@@ -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 } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}: 받침 방위각 좌표계 표기가 맞지 않습니다.`);
|
||||
}
|
||||
|
||||
@@ -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<keyof CastRebarPart, 'tieKind' | 'coverReference'>; 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<PierInput, 'columnRebar' | 'castInPlaceRebar'>, 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<CastBar, 'columnIndex'>) => {
|
||||
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] };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<keyof CopingRebarSettings, [number, number]> = {
|
||||
cover: [80, 150],
|
||||
@@ -56,8 +62,8 @@ export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, num
|
||||
spacingL: [200, 350],
|
||||
sideSpacing: [200, 300],
|
||||
sideEndGap: [300, 500],
|
||||
c14UpperSpacing: [800, 1200],
|
||||
c14LowerSpacing: [1500, 2500],
|
||||
c14UpperSpacing: [600, 1200],
|
||||
c14LowerSpacing: [600, 2500],
|
||||
};
|
||||
|
||||
/** 상면 주철근(C1–C6) 이음 (승인 2026-10-26): ignore = 이음 무시 (사양 기본), apply = 정척 초과 시 2조각 겹침. */
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { PierModel } from '../geometry';
|
||||
import { sideRowLevels } from '../coping-geom';
|
||||
import { DEFAULT_MATERIAL, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
|
||||
import { widthLayout } from './coping-main';
|
||||
import { C14_INSET, SIDE_HOOK_LEG, stairBars } from './coping';
|
||||
|
||||
export type CopingPoint = [number, number, number];
|
||||
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
|
||||
|
||||
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
|
||||
export function copingEnvelope(model: PierModel) {
|
||||
const polygons = model.copingParts.filter(p => 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;
|
||||
}
|
||||
@@ -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<number, Map<string, QuantityDiameter>>();
|
||||
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<string, QuantityDiameter>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+90
-15
@@ -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<RebarRow, 'spacing' | 'actual' | 'basis'> | 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<string, typeof layout.bars>();
|
||||
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<number, { length: number; count: number }>();
|
||||
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<string, ReturnType<typeof multicolCopingBars>>();
|
||||
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별 이음 위치·수평철근 갈고리 상세 확인 필요'] : []),
|
||||
] };
|
||||
}
|
||||
@@ -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<ColumnRebarSettings>;
|
||||
/** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Absent = the 확대기초철근 (1).md defaults. */
|
||||
footingRebar?: Partial<FootingRebarSettings>;
|
||||
/** 현장타설 전용 입력. 상세 규칙 확정 전에는 계산된 배근으로 취급하지 않는다. */
|
||||
castInPlaceRebar?: CastInPlaceRebar;
|
||||
strataRows?: StrataRow[];
|
||||
/** 현장타설 형상·암반·희생강관 입력. */
|
||||
castInPlaceShape?: CastInPlaceShapeSettings;
|
||||
}
|
||||
|
||||
/** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */
|
||||
|
||||
@@ -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<PierType, number> = { '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',
|
||||
|
||||
@@ -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 '<p>입력 오류를 확인하세요. 생성된 기둥·말뚝 철근이 없습니다.</p>';
|
||||
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 => `<polyline points="${b.pts.map(p => `${x(p[0]).toFixed(2)},${y(p[1]).toFixed(2)}`).join(' ')}" fill="none" stroke="${colour(b.kind)}" stroke-width="${b.kind === 'coupler' ? 2 : 0.7}"/>`).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 `<circle cx="${365 + (p[0] - model.columns[0].s) * sectionScale}" cy="${160 + p[2] * sectionScale}" r="${Math.max(1, b.diameterMm / 2000 * sectionScale)}" fill="#0f5c66"/>`;
|
||||
}).join('');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 470 560" role="img" aria-label="첫 기둥 및 현장타설말뚝 배근 정면·기둥 단면" style="width:100%;max-width:560px">
|
||||
<text x="20" y="20" font-size="12">정면 · 실제 생성 경로</text>
|
||||
<rect x="${x(model.columns[0].s - diameter / 2)}" y="${y(top)}" width="${diameter * scale}" height="${(top - bottom) * scale}" fill="none" stroke="#d9d6cc"/>
|
||||
${front}<line x1="20" x2="245" y1="${y(model.groundEl)}" y2="${y(model.groundEl)}" stroke="#5b6878" stroke-dasharray="4 3"/>
|
||||
<text x="20" y="${y(model.groundEl) - 5}" font-size="11">지반고 EL ${model.groundEl.toFixed(3)}</text>
|
||||
<text x="285" y="40" font-size="12">기둥 주철근 단면</text><circle cx="365" cy="160" r="70" fill="none" stroke="#d9d6cc"/>${section}
|
||||
<text x="20" y="548" font-size="11">단부·저부 P6 미확정 사항은 알림 참조 · 길이 단위 mm</text></svg>`;
|
||||
}
|
||||
+53
-5
@@ -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<THREE.Vector3> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof base>> = {}) => 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('<polyline');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { columnInputFor, columnRebar } from '../src/core/rebar/column';
|
||||
import { multicolCopingBars } from '../src/core/rebar/multicol-coping';
|
||||
import { rebarSummary } from '../src/core/rebar/summary';
|
||||
import { castRebarLayout } from '../src/core/rebar/cast-layout';
|
||||
import { buildRebar3D } from '../src/view/rebar3d';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
describe('multi-column drawing corrections', () => {
|
||||
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('양수');
|
||||
});
|
||||
});
|
||||
@@ -10,18 +10,18 @@ const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLe
|
||||
const count = (rows: ReturnType<typeof rebarSummary>['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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;i<record.pts.length;i++) {
|
||||
const a=record.pts[i-1],b=record.pts[i];
|
||||
const fractions=[...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 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<minimum){minimum=gap;worst={name:record.name,xMm:x*1000,barEl:y,concreteUndersideEl:lower};}
|
||||
}
|
||||
}
|
||||
}
|
||||
return{minimumCentreAboveUndersideMm:minimum,belowConcreteSamples:below,samples,worst};
|
||||
};
|
||||
const between=model.columns.slice(1).map((c,i)=>{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<string,number>={};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);
|
||||
Reference in new issue
Block a user