feat(view): round bar bends at the centre-line radius; bend factors as inputs on ③
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
03bb1f9929
commit
30e5e75c22
7 files changed
+146
-21
No files matched your search
@@ -181,6 +181,11 @@
|
||||
<label class="mat">D 규격 강도 <select id="mat-grade-d"><option value="300" selected>SD300</option><option value="400">SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">H 규격 강도 <select id="mat-grade-h"><option value="300">SD300</option><option value="400" selected>SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">S 규격 강도 <select id="mat-grade-s"><option value="300">SD300</option><option value="400">SD400</option><option value="500" selected>SD500</option></select></label>
|
||||
<p class="lbl">굽힘 내면반경 (× 철근 지름)</p>
|
||||
<label class="mat">D16 이하 <input id="bend-small16" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D19–D25 <input id="bend-main25" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D29–D35 <input id="bend-main35" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D38 이상 <input id="bend-main51" type="number" step="0.5" min="0.5" /></label>
|
||||
<p id="mat-note" class="muted small"></p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -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<GradeKey, number>;
|
||||
/** 굽힘 내면반경 배수 (입력). */
|
||||
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<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown };
|
||||
const r = (raw ?? {}) as { fck?: unknown; grade?: Partial<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown; bend?: Partial<Record<keyof BendFactors, unknown>> };
|
||||
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') {
|
||||
|
||||
+20
-1
@@ -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) {
|
||||
$<HTMLSelectElement>(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]);
|
||||
}
|
||||
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
||||
$<HTMLInputElement>(`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 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
|
||||
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
||||
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
||||
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
|
||||
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
||||
$<HTMLInputElement>(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
|
||||
}
|
||||
for (const k of ['D', 'H', 'S'] as const) {
|
||||
$<HTMLSelectElement>(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value));
|
||||
}
|
||||
|
||||
+49
-5
@@ -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<THREE.Vector3>();
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user