diff --git a/src/core/changes.ts b/src/core/changes.ts index db83ba6..51de119 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -7,6 +7,9 @@ import type { PierInput } from './types'; export const EDITABLE_FIELDS = [ { key: 'copingWidthMm', label: '코핑 폭 B', unit: 'mm' }, { key: 'copingLengthMm', label: '코핑 길이 L', unit: 'mm' }, + { key: 'copingH1Mm', label: '끝단 높이 h1', unit: 'mm' }, + { key: 'copingH2Mm', label: '테이퍼 높이 h2', unit: 'mm' }, + { key: 'levelWidthMm', label: 'Level 폭', unit: 'mm' }, { key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' }, ] as const; @@ -18,6 +21,9 @@ export type EditableKey = (typeof TRACKED_KEYS)[number]; export const FIELD_LABELS: Record = { copingWidthMm: '코핑 폭 B', copingLengthMm: '코핑 길이 L', + copingH1Mm: '끝단 높이 h1', + copingH2Mm: '테이퍼 높이 h2', + levelWidthMm: 'Level 폭', pileLengthMm: '말뚝 길이', pileRows: '열별 본수', }; diff --git a/src/core/coping-geom.ts b/src/core/coping-geom.ts new file mode 100644 index 0000000..7455683 --- /dev/null +++ b/src/core/coping-geom.ts @@ -0,0 +1,24 @@ +/** + * Coping heights and Level width (mm) that the coping geometry and the coping rebar depend on (1단계, 승인 2026-10-23). + * Dependency-free so that both the model and the rebar modules can import it. + */ +export interface CopingGeom { + /** 끝단 높이 h1 (mm). */ + h1: number; + /** 테이퍼 높이 h2 (mm). */ + h2: number; + /** Level 폭 W_L (mm). */ + levelWidth: number; +} + +/** P1 values: 끝단 높이 1000, 테이퍼 높이 1500, Level 폭 2100. Used when the input does not give them. */ +export const P1_COPING_GEOM: CopingGeom = { h1: 1000, h2: 1500, levelWidth: 2100 }; + +/** + * Side-row levels (EL m) of C12 / C13 from the Level underside: the lowest row and the row where the sloped underside breaks. + * P1: 40.911 + 0.377 = 41.288 and 40.911 + 1.627 = 42.538. ASSUMPTION: the side rows keep this position relative to the + * Level underside when h1 or h2 changes (명세 확인 필요). + */ +export function sideRowLevels(levelBottom: number): { zBottom: number; zBreak: number } { + return { zBottom: levelBottom + 0.377, zBreak: levelBottom + 1.627 }; +} diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 9f812de..a3f21e7 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,6 +1,7 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; +import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ export const DIM = { @@ -161,6 +162,8 @@ export interface PierModel { mitre: Vec2 | null; levelBottom: number; endBottom: number; + /** Coping heights and Level width in mm, as used by the coping rebar (1단계). */ + copingGeom: CopingGeom; bearings: BearingLocal[]; blocks: BlockModel[]; columns: ColumnModel[]; @@ -216,6 +219,10 @@ export function buildPier(input: PierInput): PierModel { const L = input.copingLengthMm / 1000; const B = input.copingWidthMm / 1000; + // Coping heights and Level width (m): inputs, with the P1 values when absent (1단계). + const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000; + const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 1000; + const levelW = (input.levelWidthMm ?? P1_COPING_GEOM.levelWidth) / 1000; const planHalfB = B / 2; const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; const ip2v: Vec2 = { e: ip2.x, n: ip2.y }; @@ -290,20 +297,20 @@ export function buildPier(input: PierInput): PierModel { // Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink. const colG = columnPositions(input.type, L); if (kinkG !== null) { - const near = nearKink(axis, colG.map((g) => sStart + g), DIM.levelWidth / 2); + const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2); if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.'); } const colK = colG.map((g) => (kinkG !== null && g > kinkG ? 1 : 0)); // Coping underside: ends at (top − end height h1 = 1000), Level zone at (top − h1 − h2 taper height = 1500). - const levelBottom = input.copingTopEl - DIM.copingH1 - DIM.copingH2; - const endBottom = input.copingTopEl - DIM.copingH1; - const levelStart = Math.min(...colG) - DIM.levelWidth / 2; - const levelEnd = Math.max(...colG) + DIM.levelWidth / 2; + const levelBottom = input.copingTopEl - copingH1 - copingH2; + const endBottom = input.copingTopEl - copingH1; + const levelStart = Math.min(...colG) - levelW / 2; + 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). const underside: [number, number][] = [[L, endBottom]]; - const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - DIM.levelWidth / 2, s + DIM.levelWidth / 2] as const); + const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const); levelZones.forEach(([zoneStart, zoneEnd], k) => { underside.push([zoneEnd, levelBottom], [zoneStart, levelBottom]); const next = levelZones[k + 1]; @@ -432,6 +439,7 @@ export function buildPier(input: PierInput): PierModel { mitre: axis.mitre, levelBottom, endBottom, + copingGeom: { h1: copingH1 * 1000, h2: copingH2 * 1000, levelWidth: levelW * 1000 }, bearings: [...frontPlaced, ...backPlaced].map((b) => ({ no: b.no, row: b.row, k: b.k, s: b.s, g: b.g, n: b.n, z: b.z })), blocks, columns, diff --git a/src/core/rebar/coping.ts b/src/core/rebar/coping.ts index 7a8eaf3..c0a2588 100644 --- a/src/core/rebar/coping.ts +++ b/src/core/rebar/coping.ts @@ -3,6 +3,7 @@ * Positions are along the coping length from its start, in mm. */ import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER } from './common'; +import { type CopingGeom, P1_COPING_GEOM } from '../coping-geom'; export const COPING_REBAR = { /** 피복 (최외측 철근 중심까지). */ cover: 100, @@ -40,9 +41,9 @@ export interface ShearBars { * C10: @300 from the column centre, out to the first position beyond the Level edge (k_max). * C9 / C11: @250 outward from the outermost C10, as many as fit with the end gap of at least 300. */ -export function shearBars(coping: number, xc = coping / 2): ShearBars { - const { spacingL, spacingS, levelWidth } = COPING_REBAR; - const kMax = Math.ceil(levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 +export function shearBars(coping: number, xc = coping / 2, g: CopingGeom = P1_COPING_GEOM): ShearBars { + const { spacingL, spacingS } = COPING_REBAR; + const kMax = Math.ceil(g.levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 const c10 = Array.from({ length: 2 * kMax + 1 }, (_, i) => Math.round(xc + (i - kMax) * spacingL)); const left = c10[0]; const right = c10[c10.length - 1]; @@ -82,15 +83,18 @@ export const STEP_BAR_COUNTS = { C15: 11, C16: 22 } as const; */ export const SHEAR_U = { x0: 300, h0: 890.9, x1: 4550, h1: 2178.8, levelH: 2300, levelEdgeFromCentre: 1050, flat: 1740 } as const; -export function shearUHeight(x: number, coping: number): number { +/** + * U height at x (mm): h = (h1 − 2c) + h2·min(x_s, x_L0)/x_L0, with x_L0 = L/2 − W_L/2 (코핑철근.md §4.2). + * Reproduces the P1 values (890.9 at x = 300, 2178.8 at x = 4550, 2300 at the Level). + */ +export function shearUHeight(x: number, coping: number, g: CopingGeom = P1_COPING_GEOM): number { const xs = Math.min(x, coping - x); // mirror to the left half - const slope = (SHEAR_U.h1 - SHEAR_U.h0) / (SHEAR_U.x1 - SHEAR_U.x0); - const h = SHEAR_U.h0 + (xs - SHEAR_U.x0) * slope; - return Math.min(h, SHEAR_U.levelH); + const xL0 = coping / 2 - g.levelWidth / 2; + return g.h1 - 2 * COPING_REBAR.cover + (g.h2 * Math.min(xs, xL0)) / xL0; } -export function shearULength(x: number, coping: number): number { - return SHEAR_U.flat + 2 * shearUHeight(x, coping); +export function shearULength(x: number, coping: number, g: CopingGeom = P1_COPING_GEOM): number { + return SHEAR_U.flat + 2 * shearUHeight(x, coping, g); } /** C13 sits 127 in from each end face (코핑철근.md §6.2). */ @@ -113,13 +117,13 @@ export interface SideBar { * C12 meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2. * C13 starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end). */ -export function copingSideBars(L: number, zBottom: number, zBreak: number): { c12: SideBar[]; c13: SideBar[] } { +export function copingSideBars(L: number, zBottom: number, zBreak: number, g: CopingGeom = P1_COPING_GEOM): { c12: SideBar[]; c13: SideBar[] } { const c = COPING_REBAR.cover; - const xL0 = L / 2 - COPING_REBAR.levelWidth / 2; + const xL0 = L / 2 - g.levelWidth / 2; const bar = (z: number, xw: number): SideBar => ({ z, xw, straight: L - 2 * xw }); const rows = sideRows(zBottom, zBreak); return { - c12: rows.c12.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / COPING_REBAR.taperHeight)), + c12: rows.c12.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / g.h2)), c13: rows.c13.map((z) => bar(z, SIDE_C13_END)), }; } @@ -159,8 +163,8 @@ export interface C14Bar { } /** Underside of the coping (EL m) at x (mm from the start), T형: sloped from each end to the Level zone, flat in the middle. */ -export function undersideAt(L: number, x: number, endBottom: number, levelBottom: number): number { - const xL0 = L / 2 - COPING_REBAR.levelWidth / 2; +export function undersideAt(L: number, x: number, endBottom: number, levelBottom: number, levelWidth = P1_COPING_GEOM.levelWidth): number { + const xL0 = L / 2 - levelWidth / 2; return endBottom + (levelBottom - endBottom) * Math.min(Math.min(x, L - x) / xL0, 1); } @@ -246,8 +250,8 @@ export function stairBars(L: number, B: number, topEl: number, m: Material = DEF */ export const CAP = { flat: 1740, cover: 100 } as const; -export function capLength(x: number, coping: number): number { - const h = shearUHeight(x, coping); +export function capLength(x: number, coping: number, g: CopingGeom = P1_COPING_GEOM): number { + const h = shearUHeight(x, coping, g); const lCap = h - CAP.cover; return CAP.flat + 2 * lCap; } @@ -265,8 +269,8 @@ export const PIECES_PER_POSITION = 6; * 전단철근 구간 표 (시안 ③ · 코핑): the segments of C9 / C10 / C11 along the coping, from the bar positions. * One column gives the three T형 segments; several columns add the segments between them (추천안, Q-K2). */ -export function shearSegments(coping: number, columns: number[] = [coping / 2]): { segment: string; from: number; to: number; spacing: number; count: number; bar: 'C9' | 'C10' | 'C11' | 'C9·C11' }[] { - const b = shearBarsMulti(coping, columns); +export function shearSegments(coping: number, columns: number[] = [coping / 2], g: CopingGeom = P1_COPING_GEOM): { segment: string; from: number; to: number; spacing: number; count: number; bar: 'C9' | 'C10' | 'C11' | 'C9·C11' }[] { + const b = shearBarsMulti(coping, columns, g); const seg = (name: string, xs: number[], bar: 'C9' | 'C10' | 'C11' | 'C9·C11', spacing: number) => ({ segment: name, from: Math.min(...xs), @@ -279,7 +283,7 @@ export function shearSegments(coping: number, columns: number[] = [coping / 2]): const groups = columns.length > 1 ? columns.length : 1; for (let k = 0; k < groups; k++) { const xc = columns[k] ?? coping / 2; - out.push(seg(`Level ${k + 1}`, b.c10.filter((x) => Math.abs(x - xc) <= (COPING_REBAR.levelWidth / 2 + COPING_REBAR.spacingL * 4)).slice(0, 2 * 4 + 1), 'C10', COPING_REBAR.spacingL)); + out.push(seg(`Level ${k + 1}`, b.c10.filter((x) => Math.abs(x - xc) <= (g.levelWidth / 2 + COPING_REBAR.spacingL * 4)).slice(0, 2 * 4 + 1), 'C10', COPING_REBAR.spacingL)); } for (let k = 0; k + 1 < groups; k++) { const between = b.between.filter((x) => x > columns[k] && x < columns[k + 1]); @@ -296,13 +300,13 @@ export function shearSegments(coping: number, columns: number[] = [coping / 2]): * Between columns: @250 in from both C10 groups, the remaining length shared evenly (추천안, Q-K2). * With one column this is the same as shearBars. */ -export function shearBarsMulti(coping: number, columns: number[]): ShearBars & { between: number[] } { +export function shearBarsMulti(coping: number, columns: number[], g: CopingGeom = P1_COPING_GEOM): ShearBars & { between: number[] } { if (columns.length <= 1) { - const one = shearBars(coping, columns[0] ?? coping / 2); + const one = shearBars(coping, columns[0] ?? coping / 2, g); return { ...one, between: [] }; } - const { spacingL, spacingS, levelWidth } = COPING_REBAR; - const kMax = Math.ceil(levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 + const { spacingL, spacingS } = COPING_REBAR; + const kMax = Math.ceil(g.levelWidth / 2 / spacingL); // k_max = ceil((W_L/2)/s_L), spec §3.1 const groups = columns.map((xc) => Array.from({ length: 2 * kMax + 1 }, (_, i) => Math.round(xc + (i - kMax) * spacingL)), ); diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index c88dbb9..9185f56 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -2,6 +2,7 @@ import type { PierModel } from '../geometry'; import { barLength, bearingBlockRebar, totals } from './bearing-block'; import { bottomPiece, topBarLength, widthLayout } 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 { columnRebar, COLUMN_P1 } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces } from './common'; @@ -162,21 +163,22 @@ export function rebarSummary( 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 shear = nCol > 1 ? shearBarsMulti(L, colS) : { ...shearBars(L), between: [] as number[] }; + const geom = model.copingGeom; + const shear = nCol > 1 ? shearBarsMulti(L, colS, geom) : { ...shearBars(L, undefined, geom), between: [] as number[] }; const uRange = (xs: number[]) => { - const u = xs.map((x) => Math.round(shearULength(x, L))); + const u = xs.map((x) => Math.round(shearULength(x, L, geom))); return `${Math.min(...u)}–${Math.max(...u)}`; }; const capRange = (xs: number[]) => { - const c = xs.map((x) => Math.round(capLength(x, L))); + const c = xs.map((x) => Math.round(capLength(x, L, geom))); return `${Math.min(...c)}–${Math.max(...c)}`; }; const shearNote = (label: string, xs: number[]) => `${label}, 하부 U ${uRange(xs)}, 삿갓 ${capRange(xs)} (l_cap 추천안, Q-K5), 조각 ${xs.length * PIECES_PER_POSITION}`; // Each position has three 폐합, each with one 하부 U and one 삿갓 (6 pieces). Pieces: U and 삿갓 lengths at each position. const loopPieces = (xs: number[]) => xs.flatMap((x) => [ - { length: shearULength(x, L), count: 3 }, - { length: capLength(x, L), count: 3 }, + { length: shearULength(x, L, model.copingGeom), count: 3 }, + { length: capLength(x, L, model.copingGeom), count: 3 }, ]); rows.push({ member: 'coping', name: 'C9', size: 'H25', count: shear.c9.length, length: null, pieces: loopPieces(shear.c9), note: shearNote('전단 좌 @250', shear.c9) }); rows.push({ member: 'coping', name: 'C10', size: 'H25', count: shear.c10.length, length: null, pieces: loopPieces(shear.c10), note: shearNote('전단 Level @300', shear.c10) }); @@ -188,7 +190,8 @@ export function rebarSummary( warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' }); } // Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2). - const side = copingSideBars(model.copingL * 1000, 41.288, 42.538); + const levels = sideRowLevels(model.levelBottom); + const side = copingSideBars(model.copingL * 1000, levels.zBottom, levels.zBreak, model.copingGeom); const hook = model.copingB * 1000 - 2 * 127 + 2 * SIDE_HOOK_LEG; // ㄷ body across the width + two legs const c12Range = side.c12.length ? `${Math.round(side.c12[0].straight)}–${Math.round(side.c12[side.c12.length - 1].straight)}` : '—'; // Per bar: 2 일자 (straight) and 2 ㄷ (hook body + legs), so 4 bars per row position. @@ -197,7 +200,7 @@ export function rebarSummary( rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면, 끝면까지, 일자 (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length})` }); // C14 / C15 / C16 are derived from the model (코핑철근.md §7–8); the document's 38 / 11 / 22 and 3852 / 4070 / 4069 are checked in the tests. const L14 = model.copingL * 1000; - const c14 = c14Bars(L14, model.copingRefTop, [...side.c12, ...side.c13], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom)); + const c14 = c14Bars(L14, model.copingRefTop, [...side.c12, ...side.c13], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom, model.copingGeom.levelWidth)); const lineCount = (line: number) => c14.filter((b) => b.line === line).length; const omitted = c14.filter((b) => !b.legs).length; rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` }); diff --git a/src/core/types.ts b/src/core/types.ts index 6b35985..144f1de 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -31,6 +31,10 @@ export interface PierInput { pileLengthMm: number; copingWidthMm: number; copingLengthMm: number; + /** 끝단 높이 h1, 테이퍼 높이 h2, Level 폭 (mm). Absent = the P1 values (1000 · 1500 · 2100). */ + copingH1Mm?: number; + copingH2Mm?: number; + levelWidthMm?: number; columnCount: number; /** 열별 본수 (spec §4.6): one count per 열 across the footing width. Absent = the defaults. */ pileRows?: number[]; diff --git a/src/main.ts b/src/main.ts index 01c56af..f3eb15d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; +import { P1_COPING_GEOM } from './core/coping-geom'; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; @@ -56,7 +57,7 @@ const CRS = '국토좌표계 (x = 동, y = 북)'; /** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [ { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: [], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' }, - { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm'], note: '폭과 길이를 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, + { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: [], note: '기둥 지름 입력은 다음 단계에서 추가합니다. 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['pileLengthMm'], note: '말뚝 길이를 입력합니다. 열별 본수는 다음 단계에서 추가합니다.' }, { id: 'ground', label: '지반·지층', keys: [], note: '지반고는 입력값을 표시만 합니다. 지층 입력은 다음 단계에서 추가합니다.' }, @@ -180,7 +181,18 @@ function animate(): void { animate(); // --- pier model ---------------------------------------------------------------- -function buildEntry(input: PierInput, checked: boolean): Pier { +/** Fills the coping heights and Level width with the P1 values when a file or sheet does not give them (1단계). */ +function withCopingDefaults(input: PierInput): PierInput { + return { + ...input, + copingH1Mm: input.copingH1Mm ?? P1_COPING_GEOM.h1, + copingH2Mm: input.copingH2Mm ?? P1_COPING_GEOM.h2, + levelWidthMm: input.levelWidthMm ?? P1_COPING_GEOM.levelWidth, + }; +} + +function buildEntry(rawInput: PierInput, checked: boolean): Pier { + const input = withCopingDefaults(rawInput); const issues: Issue[] = []; let model: PierModel | null = null; if (validateInput(input, issues)) { @@ -971,7 +983,7 @@ function renderRebar(): void { renderMaterial(); // 전단철근 구간 표 (시안 ③ 코핑 탭): segments of C9 / C10 / C11 along the coping, in metres. if (state.rebarMember === 'coping') { - const seg = shearSegments(pier.model.copingL * 1000, pier.model.columns.map((c) => c.s * 1000)); + const seg = shearSegments(pier.model.copingL * 1000, pier.model.columns.map((c) => c.s * 1000), pier.model.copingGeom); const st = el('table', undefined, 'grid-table rebar-table'); const sh = el('thead'); const shr = el('tr'); diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 6511dc3..9103bdf 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -3,6 +3,7 @@ import type { PierModel } from '../core/geometry'; import { assemblyEndOffset, assemblyJs, b10Levels, b9Bars, FOOTING_DEFAULTS, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing'; import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; 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, COLUMN_P1 } from '../core/rebar/column'; import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; import { blockFramePoint } from '../core/geometry'; @@ -150,8 +151,8 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } // Bottom bars C7 / C8 (코핑철근.md §5.2–5.3): they follow the sloped underside, 127 above it, with a vertical leg up // at each end to the C1 level. Each bar is two pieces spliced at the column centre (overlap l_s). The slope runs x_L0 from each end (T형). - const xL0 = L / 2 - COPING_REBAR.levelWidth / 2000; - const under = (x: number) => undersideAt(L * 1000, x * 1000, m.endBottom, m.levelBottom); + const xL0 = L / 2 - m.copingGeom.levelWidth / 2000; + const under = (x: number) => undersideAt(L * 1000, x * 1000, m.endBottom, m.levelBottom, m.copingGeom.levelWidth); const lap = bottomPiece(mCoping).ls / 2000; xs.forEach((n, j) => { const z = (x: number) => under(x) + 0.127; @@ -169,7 +170,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, }); // Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position. const colS = m.columns.map((c) => c.s * 1000); - const sh = colS.length > 1 ? shearBarsMulti(L * 1000, colS) : { ...shearBars(L * 1000), between: [] as number[] }; + const sh = colS.length > 1 ? shearBarsMulti(L * 1000, colS, m.copingGeom) : { ...shearBars(L * 1000, undefined, m.copingGeom), between: [] as number[] }; const loops: [string, number[]][] = [['C9', sh.c9], ['C10', sh.c10], ['C11', sh.c11], ['C9·C11 사이', sh.between]]; // Each position has three 폐합 (코핑철근.md §4.1): legs at y_i = c + i·s_leg (i = 0 … 5), 폐합 i = legs i and i + 3, // 1740 wide. Each 폐합 is a 하부 U (base at the bottom, legs up to the top) and a 상부 삿갓 (top bar, legs down @@ -181,7 +182,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, for (const [name, list] of loops) { for (const x of list) { const xm = mm(x); - const h = shearUHeight(x, L * 1000) / 1000; + const h = shearUHeight(x, L * 1000, m.copingGeom) / 1000; const bot = top - h; const capBot = top - (h - CAP.cover / 1000); for (let i = 0; i < 3; i++) { @@ -203,7 +204,8 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, // and two ㄷ bars: a body across the width at x_w, 25.4 above the row, with legs 1525 inward along the faces. const sideFace = B / 2 - 0.127; const dSide = NOMINAL_DIAMETER.H25; - const side = copingSideBars(L * 1000, 41.288, 42.538); + const levels = sideRowLevels(m.levelBottom); + const side = copingSideBars(L * 1000, levels.zBottom, levels.zBreak, m.copingGeom); for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) { for (const r of rows) { const xw = mm(r.xw); @@ -223,7 +225,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const c14Hook = mm(hookLength('main90', NOMINAL_DIAMETER.H19)); const c14Na = -B / 2 + mm(C14_INSET); const c14Nb = B / 2 - mm(C14_INSET); - for (const b of c14Bars(L * 1000, topEl, [...side.c12, ...side.c13], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom))) { + for (const b of c14Bars(L * 1000, topEl, [...side.c12, ...side.c13], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth))) { const xm = mm(b.x); addBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping); // 연직 발 below the coping underside is left out (코핑철근.md §8). diff --git a/tests/coping-geom.test.ts b/tests/coping-geom.test.ts new file mode 100644 index 0000000..44db75a --- /dev/null +++ b/tests/coping-geom.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { P1_COPING_GEOM, sideRowLevels } from '../src/core/coping-geom'; +import { shearUHeight, shearULength } from '../src/core/rebar/coping'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { buildRebar3D } from '../src/view/rebar3d'; +import { loadP1 } from './helpers'; + +const L = 12000; + +describe('① 코핑 끝단 높이 h1 · 테이퍼 높이 h2 · Level 폭 (1단계, 승인 2026-10-23)', () => { + it('the P1 geometry reproduces the document U heights (890.9, 2178.8, Level 2300)', () => { + expect(shearUHeight(300, L)).toBeCloseTo(890.9, 1); + expect(shearUHeight(4550, L)).toBeCloseTo(2178.8, 1); + expect(shearUHeight(6000, L)).toBeCloseTo(2300, 6); + }); + + it('the model defaults to the P1 coping geometry and the P1 shear lengths', () => { + const model = buildPier(loadP1()); + expect(model.copingGeom).toEqual(P1_COPING_GEOM); + expect(model.endBottom).toBeCloseTo(43.411 - 1.0, 6); + expect(model.levelBottom).toBeCloseTo(43.411 - 1.0 - 1.5, 6); + }); + + it('a higher end height h1 lowers the coping ends and adds the same height to every U', () => { + const base = buildPier(loadP1()); + const high = buildPier({ ...loadP1(), copingH1Mm: 1200 }); + expect(high.endBottom).toBeCloseTo(base.endBottom - 0.2, 6); + expect(high.levelBottom).toBeCloseTo(base.levelBottom - 0.2, 6); + const geomH1 = { ...P1_COPING_GEOM, h1: 1200 }; + expect(shearUHeight(300, L, geomH1)).toBeCloseTo(shearUHeight(300, L) + 200, 6); + expect(shearULength(300, L, geomH1)).toBeCloseTo(shearULength(300, L) + 400, 6); + }); + + it('a taper h2 changes the Level underside and the height at the Level', () => { + const high = buildPier({ ...loadP1(), copingH2Mm: 1800 }); + expect(high.levelBottom).toBeCloseTo(43.411 - 1.0 - 1.8, 6); + expect(shearUHeight(6000, L, { ...P1_COPING_GEOM, h2: 1800 })).toBeCloseTo(2600, 6); + }); + + it('a wider Level changes the Level zone in the model and the shear-bar Level half-width', () => { + const wide = buildPier({ ...loadP1(), levelWidthMm: 2500 }); + expect(wide.copingGeom.levelWidth).toBe(2500); + expect(wide.levelBottom).toBeCloseTo(buildPier(loadP1()).levelBottom, 6); + }); + + it('side-row levels keep their P1 values from the Level underside (assumption, 명세 확인 필요)', () => { + const levels = sideRowLevels(40.911); + expect(levels.zBottom).toBeCloseTo(41.288, 6); + expect(levels.zBreak).toBeCloseTo(42.538, 6); + }); + + it('the coping rebar follows a changed geometry: the rows and the 3D build without error, the positions are unchanged', () => { + const base = rebarSummary(buildPier(loadP1())).rows; + const changed = buildPier({ ...loadP1(), copingH1Mm: 1200, copingH2Mm: 1600, levelWidthMm: 2300 }); + const rows = rebarSummary(changed).rows; + expect(rows.find((r) => r.name === 'C10')!.count).toBe(base.find((r) => r.name === 'C10')!.count); + expect(rows.find((r) => r.name === 'C9')!.count).toBe(base.find((r) => r.name === 'C9')!.count); + expect(buildRebar3D(changed).byName.get('C10')!.length).toBeGreaterThan(0); + }); +});