From 30e5e75c2293e1a3d0a3bc62d184899dc9df1c21 Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 10:13:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(view):=20round=20bar=20bends=20at=20the=20?= =?UTF-8?q?centre-line=20radius;=20bend=20factors=20as=20inputs=20on=20?= =?UTF-8?q?=E2=91=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bars drawn through several points are one tube each, with every bend rounded by R·tan(φ/2) at the centre-line radius (inner factor × d + d/2). The inside-radius factors (D16 and below, D19–D25, D29–D35, D38 and above) are inputs in 공통 설정, with KDS-based defaults 2 / 3 / 4 / 5. The size-class limits sit in one place (SIZE_CLASS), which also fixes D25 (25.4) being read as the D29–D35 class in hookLength. The helper bendDeduction gives the centre-line deduction for the length option, which is not yet applied to the 철근표 lengths. Co-Authored-By: Claude Haiku 5.5 --- index.html | 5 ++++ src/core/rebar/common.ts | 54 ++++++++++++++++++++++++++++++++++----- src/main.ts | 21 ++++++++++++++- src/view/rebar3d.ts | 54 +++++++++++++++++++++++++++++++++++---- tests/project.test.ts | 4 +-- tests/rebar-grade.test.ts | 19 ++++++++++++-- tests/rebar3d.test.ts | 10 ++++---- 7 files changed, 146 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 9beaf1c..abaa213 100644 --- a/index.html +++ b/index.html @@ -181,6 +181,11 @@ +

굽힘 내면반경 (× 철근 지름)

+ + + +

diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index 9ddb59b..4e65548 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -8,15 +8,50 @@ export const GRADE_CHOICES = [300, 400, 500] as const; /** Bar family by 규격 prefix: D · H · S. */ export type GradeKey = 'D' | 'H' | 'S'; -/** Material inputs of ③ 공통 설정 (spec §6.2). fck and the grade per prefix are inputs. */ +/** + * Inside bend radius as a multiple of the nominal diameter (입력, 2026-10-23). Defaults follow KDS 14 20 52 as + * recorded in the plan; confirm against the code. small16 is for D16 and below (ties and stirrups). + */ +export interface BendFactors { + small16: number; + main25: number; + main35: number; + main51: number; +} + +export const DEFAULT_BEND: BendFactors = { small16: 2, main25: 3, main35: 4, main51: 5 }; + +/** + * Size classes by nominal diameter (mm). The limits lie between the sizes: D16 15.9, D25 25.4, D29 28.6, D35 34.9, + * so 26 and 36 separate D25 from D29 and D35 from D38. + */ +export const SIZE_CLASS = { small: 17, d25: 26, d35: 36 } as const; + +/** Centre-line bend radius (mm) of a bar of nominal diameter db: inner radius (factor × db) + db/2. */ +export function bendRadius(db: number, bend: BendFactors): number { + const factor = db <= SIZE_CLASS.small ? bend.small16 : db <= SIZE_CLASS.d25 ? bend.main25 : db <= SIZE_CLASS.d35 ? bend.main35 : bend.main51; + return factor * db + db / 2; +} + +/** + * Length the centre line loses at a bend of deflection phi (rad) compared with the sharp corner of the same bar: + * 2R·tan(φ/2) − Rφ. For a 90° bend this is (2 − π/2)·R ≈ 0.429 R. Used by the centre-line length option. + */ +export function bendDeduction(phi: number, R: number): number { + return 2 * R * Math.tan(phi / 2) - R * phi; +} + +/** Material inputs of ③ 공통 설정 (spec §6.2). fck, the grade per prefix and the bend factors are inputs. */ export interface Material { /** 콘크리트 강도 fck (MPa). */ fck: number; /** 규격 접두어별 철근 강도 (MPa): D = SD300, H = SD400, S = SD500 (입력). */ grade: Record; + /** 굽힘 내면반경 배수 (입력). */ + bend: BendFactors; } -export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 } }; +export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 }, bend: DEFAULT_BEND }; /** Yield strength (MPa) of a bar from its 규격, e.g. H19 → the H grade. */ export function fyOf(m: Material, size: string): number { @@ -28,14 +63,21 @@ export function fyOf(m: Material, size: string): number { * A value outside GRADE_CHOICES falls back to the default for that prefix. */ export function materialFrom(raw: unknown): Material { - const r = (raw ?? {}) as { fck?: unknown; grade?: Partial>; fyH?: unknown; fyS?: unknown }; + const r = (raw ?? {}) as { fck?: unknown; grade?: Partial>; fyH?: unknown; fyS?: unknown; bend?: Partial> }; + const factor = (v: unknown, fallback: number) => (typeof v === 'number' && v > 0 ? v : fallback); + const bend: BendFactors = { + small16: factor(r.bend?.small16, DEFAULT_BEND.small16), + main25: factor(r.bend?.main25, DEFAULT_BEND.main25), + main35: factor(r.bend?.main35, DEFAULT_BEND.main35), + main51: factor(r.bend?.main51, DEFAULT_BEND.main51), + }; const pick = (v: unknown, fallback: number) => ((GRADE_CHOICES as readonly number[]).includes(v as number) ? (v as number) : fallback); const grade = { D: pick(r.grade?.D, DEFAULT_MATERIAL.grade.D), H: pick(r.grade?.H ?? r.fyH, DEFAULT_MATERIAL.grade.H), S: pick(r.grade?.S ?? r.fyS, DEFAULT_MATERIAL.grade.S), }; - return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade }; + return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade, bend }; } /** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */ @@ -106,8 +148,8 @@ export type HookKind = 'main90' | 'tie90' | 'stirrup135'; */ export function hookLength(kind: HookKind, db: number): number { if (kind === 'main90') { - if (db <= 25) return 15.5 * db; - if (db <= 35) return 16.5 * db; + if (db <= SIZE_CLASS.d25) return 15.5 * db; + if (db <= SIZE_CLASS.d35) return 16.5 * db; throw new Error(`주철근 90° 갈고리 표에 없는 지름입니다 (D${db}).`); } if (kind === 'tie90') { diff --git a/src/main.ts b/src/main.ts index 5005068..4778140 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,7 @@ import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; 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 FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; +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 { 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'; @@ -1000,6 +1000,9 @@ function renderMaterial(): void { for (const k of ['d', 'h', 's'] as const) { $(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]); } + for (const k of ['small16', 'main25', 'main35', 'main51'] as const) { + $(`bend-${k}`).value = String(m.bend[k]); + } $('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; } @@ -1047,6 +1050,19 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void { box.appendChild(row); } +/** Bend factor (× diameter) input on 공통 설정. Must be a positive number. */ +function setBend(key: keyof BendFactors, raw: string): void { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + setStatus('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.'); + renderMaterial(); + return; + } + state.material = { ...state.material, bend: { ...state.material.bend, [key]: value } }; + setupScene(); + renderAll(); +} + function setMaterial(key: 'fck' | GradeKey, raw: string): void { const value = Number(raw); const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value); @@ -1461,6 +1477,9 @@ $('nav-shape').addEventListener('click', () => setStage('shap $('nav-plan').addEventListener('click', () => setStage('plan')); $('nav-rebar').addEventListener('click', () => setStage('rebar')); $('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value)); +for (const k of ['small16', 'main25', 'main35', 'main51'] as const) { + $(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value)); +} for (const k of ['D', 'H', 'S'] as const) { $(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value)); } diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index ea19ffc..02ed773 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -5,7 +5,7 @@ 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 { columnRebar, COLUMN_P1 } from '../core/rebar/column'; import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; -import { DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common'; +import { bendRadius, DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common'; import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color'; import { stretchMatrix } from './scene'; @@ -62,6 +62,50 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const B = m.copingB; const straight = m.kink === null; + /** + * A bar through several points as one tube, each bend rounded to its centre-line radius (수정 2026-10-23). + * A bend of deflection φ is cut back by t = R·tan(φ/2) on both sides and joined by a quadratic curve through the corner. + */ + const addPath = (name: string, k: number, pts: [number, number, number][], dMm: number, color: number) => { + if (pts.length < 2) return; + const R = mm(bendRadius(dMm, common.bend)); + const pv = pts.map((p) => new THREE.Vector3(p[0], p[1], p[2])); + const path = new THREE.CurvePath(); + let cur = pv[0].clone(); + for (let i = 1; i < pv.length - 1; i++) { + const prev = pv[i - 1]; + const corner = pv[i]; + const next = pv[i + 1]; + const u = corner.clone().sub(prev); + const v = next.clone().sub(corner); + const lenIn = u.length(); + const lenOut = v.length(); + if (lenIn < 1e-9 || lenOut < 1e-9) continue; + u.normalize(); + v.normalize(); + const phi = Math.acos(Math.min(1, Math.max(-1, u.dot(v)))); + // Cut back by the tangent length, never more than half of either adjacent piece. + const t = Math.min(R * Math.tan(phi / 2), lenIn / 2, lenOut / 2); + const a = corner.clone().addScaledVector(u, -t); + const b = corner.clone().addScaledVector(v, t); + if (a.distanceTo(cur) > 1e-9) path.add(new THREE.LineCurve3(cur, a)); + path.add(new THREE.QuadraticBezierCurve3(a, corner, b)); + cur = b; + } + const last = pv[pv.length - 1]; + if (last.distanceTo(cur) > 1e-9) path.add(new THREE.LineCurve3(cur, last)); + const segments = Math.min(400, Math.max(12, Math.round(path.getLength() * 100))); + const geo = new THREE.TubeGeometry(path, segments, mm(dMm) / 2, 6, false); + geo.applyMatrix4(Ms[k]); + const mat = new THREE.MeshStandardMaterial({ color: rebarColor(color), roughness: 0.45, metalness: 0.1 }); + const mesh = new THREE.Mesh(geo, mat); + mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) }; + group.add(mesh); + const list = byName.get(name) ?? []; + list.push(mesh); + byName.set(name, list); + }; + const addBar = (name: string, k: number, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => { const dir = new THREE.Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]); const len = dir.length(); @@ -100,7 +144,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, [L - xk, z, n], [L - xk, z - hookTop, n], ]; - for (let s = 0; s + 1 < pts.length; s++) addBar(name, 0, pts[s], pts[s + 1], NOMINAL_DIAMETER.H29, COLOR.coping); + addPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping); }); } // Bottom bars C7 / C8 (코핑철근.md §5.2–5.3): they follow the sloped underside, 127 above it, with a vertical leg up @@ -119,7 +163,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const right = left.map(([x, y, zz]): [number, number, number] => [L - x, y, zz]); const name = j % 2 === 1 ? 'C7' : 'C8'; for (const pts of [left, right]) { - for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], NOMINAL_DIAMETER.H29, COLOR.coping); + addPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping); } }); // Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position. @@ -323,7 +367,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const off = legOffsets(); // One name, several cylinders: each segment of the path is one bar piece. const path = (name: string, pts: [number, number, number][], dMm: number) => { - for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], dMm, COLOR.footing); + addPath(name, 0, pts, dMm, COLOR.footing); }; // B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight. const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => { @@ -375,7 +419,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, // B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12). const polyline = (name: string, pts: [number, number, number][], dMm: number) => { - for (let k = 0; k + 1 < pts.length; k++) addBar(name, 0, pts[k], pts[k + 1], dMm, COLOR.footing); + addPath(name, 0, pts, dMm, COLOR.footing); }; const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296 const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246 diff --git a/tests/project.test.ts b/tests/project.test.ts index b629ab8..c678722 100644 --- a/tests/project.test.ts +++ b/tests/project.test.ts @@ -68,8 +68,8 @@ describe('project file round trip (spec §8, plan B3)', () => { describe('③ 공통 설정 in the project file', () => { it('fck and fy are kept through save and open; older files get the defaults through the app', () => { const p = sampleProject(); - const withCommon = { ...p, common: { fck: 35, grade: { D: 300, H: 400, S: 500 } } }; - expect(parseProject(serializeProject(withCommon)).common).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 500 } }); + const withCommon = { ...p, common: { fck: 35, grade: { D: 300, H: 400, S: 500 }, bend: { small16: 2, main25: 3, main35: 4, main51: 5 } } }; + expect(parseProject(serializeProject(withCommon)).common).toEqual(withCommon.common); expect(parseProject(serializeProject(p)).common).toBeUndefined(); }); }); diff --git a/tests/rebar-grade.test.ts b/tests/rebar-grade.test.ts index eae6e53..5af90e2 100644 --- a/tests/rebar-grade.test.ts +++ b/tests/rebar-grade.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { DEFAULT_MATERIAL, fyOf, isAssumedMaterial, materialFrom } from '../src/core/rebar/common'; +import { DEFAULT_BEND, DEFAULT_MATERIAL, fyOf, isAssumedMaterial, materialFrom } from '../src/core/rebar/common'; import { rebarSummary } from '../src/core/rebar/summary'; import { loadP1 } from './helpers'; @@ -19,7 +19,7 @@ describe('③ 철근 강도: 규격 접두어별 입력 (승인 2026-10-23)', () }); it('an older file that stored fyH / fyS is read into the grade map', () => { - expect(materialFrom({ fck: 35, fyH: 400, fyS: 400 })).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 400 } }); + expect(materialFrom({ fck: 35, fyH: 400, fyS: 400 })).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 400 }, bend: DEFAULT_BEND }); }); it('a value outside SD300 · SD400 · SD500 falls back to the default for its prefix', () => { @@ -34,3 +34,18 @@ describe('③ 철근 강도: 규격 접두어별 입력 (승인 2026-10-23)', () expect(same).toBe(base); }); }); + +import { bendDeduction, bendRadius } from '../src/core/rebar/common'; + +describe('굽힘 반경 (승인 2026-10-23, 입력)', () => { + it('centre-line radius = inner factor × d + d/2, by size class', () => { + expect(bendRadius(25.4, DEFAULT_BEND)).toBeCloseTo(3 * 25.4 + 12.7, 6); // H25: main25 + expect(bendRadius(28.6, DEFAULT_BEND)).toBeCloseTo(4 * 28.6 + 14.3, 6); // H29: main35 + expect(bendRadius(15.9, DEFAULT_BEND)).toBeCloseTo(2 * 15.9 + 7.95, 6); // H16: small16 + }); + + it('a 90° bend loses (2 − π/2)·R of centre line against the sharp corner', () => { + const R = 100; + expect(bendDeduction(Math.PI / 2, R)).toBeCloseTo((2 - Math.PI / 2) * R, 6); + }); +}); diff --git a/tests/rebar3d.test.ts b/tests/rebar3d.test.ts index d7c6c2b..73b64e8 100644 --- a/tests/rebar3d.test.ts +++ b/tests/rebar3d.test.ts @@ -108,11 +108,11 @@ describe('확대기초 B9 · B10 · B11 · B12 in 3D (확대기초철근 (1).md expect(rb.byName.get('B9')!.length).toBe(306 * 3); }); - it('B10-1 / B10-2: 18 each (9 levels × 2 faces), each three pieces; B11 60, B12 47', () => { - expect(rb.byName.get('B10-1')!.length).toBe(18 * 3); - expect(rb.byName.get('B10-2')!.length).toBe(18 * 3); - expect(rb.byName.get('B11')!.length).toBe(60 * 3); - expect(rb.byName.get('B12')!.length).toBe(47 * 3); + it('B10-1 / B10-2: 18 each (9 levels × 2 faces); B11 60, B12 47 — one tube per bar (bends rounded, 수정 2026-10-23)', () => { + expect(rb.byName.get('B10-1')!.length).toBe(18); + expect(rb.byName.get('B10-2')!.length).toBe(18); + expect(rb.byName.get('B11')!.length).toBe(60); + expect(rb.byName.get('B12')!.length).toBe(47); }); });