Files
Pier_v1/src/view/rebar3d.ts
T
ypshimandClaude Haiku 5.5 f37a0bf9ce fix(rebar): column heights Hc and h_cop from the model (Level underside, step top) instead of P1 constants
Hc = Level underside − footing top, and h_cop = step top at the column centre − Level underside. P1 gives 12703 and 2644,
unchanged. With taper h2 = 3000 the Level underside moves down, so Hc = 11203 and the P2 top (Level underside + l_d)
moves to 13039, while the P1 top stays at the step top (15147). The 3D and the 철근표 use the same heights.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-23 13:10:51 +09:00

532 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
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, columnInputFor, COLUMN_P1 } from '../core/rebar/column';
import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block';
import { blockFramePoint } from '../core/geometry';
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';
/**
* 3D rebar of the ③ screen. Positions come from the rebar modules (no new rules here). Each bar is a cylinder
* named by its 철근표 number (C1, P3, B5, S2 …), so the table and the model can point at each other.
* Local frame: x = along the axis s, y = absolute EL (m), z = across n. Stretch matrices map it to the scene.
*/
export interface RebarScene {
group: THREE.Group;
byName: Map<string, THREE.Mesh[]>;
/** Highlight one bar name (or none): the others are dimmed. */
/** Emphasise the bars in `names` (all when null). The others are dimmed, or hidden when `isolate` is on (선택만 보기). */
show(names: ReadonlySet<string> | null, isolate: boolean): void;
/** Colour the bars by role (default, spec §6.7), by nominal diameter, or by member. */
setColorMode(mode: RebarColorMode): void;
/** Legend entries of the mode, only for the items this model has. Role mode has none (the role colours are in §6.7). */
legend(mode: RebarColorMode): { label: string; color: string }[];
}
export type RebarColorMode = 'role' | 'diameter' | 'member';
/** Okabe–Ito colours (colour-blind safe), one per diameter group in order; grey for anything else. */
const DIAMETER_COLORS = [0xe69f00, 0x56b4e9, 0x009e73, 0x0072b2, 0xd55e00, 0xcc79a7, 0x000000, 0x8c8c8c];
const OTHER_COLOR = 0x8c8c8c;
const MEMBER_COLORS: Record<RebarMemberKey, number> = { bearing: 0xe69f00, coping: 0x0072b2, column: 0x009e73, footing: 0xcc79a7, other: OTHER_COLOR };
const MEMBER_LABELS: Record<RebarMemberKey, string> = { bearing: '받침블럭', coping: '코핑', column: '기둥', footing: '확대기초', other: '기타' };
const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
/** Rebar is drawn a little deeper than the 6.7 colours so it reads through the half-transparent members (승인 2026-10-23). */
const REBAR_SHADE = 0.8;
const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
const COLOR = {
coping: 0xb4531f,
shear: 0x6b3fa0,
column: 0x0f5c66,
tie: 0x6b3fa0,
couplerMain: 0x0b3f46,
couplerTie: 0x553088,
footing: 0x0f5c66,
footingBottom: 0x2e7d32,
block: 0xa07a10,
} as const;
const mm = (v: number) => v / 1000;
export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene {
const mCoping = materialFor(common, byMember, 'coping');
const mColumn = materialFor(common, byMember, 'column');
const group = new THREE.Group();
const byName = new Map<string, THREE.Mesh[]>();
const Ms = m.stretches.map((_, k) => stretchMatrix(m, k));
const L = m.copingL;
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();
if (len < 1e-6) return;
const geo = new THREE.CylinderGeometry(mm(dMm) / 2, mm(dMm) / 2, len, 6);
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()));
geo.translate((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2);
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);
};
// Coping (straight coping only; a bent coping is not drawn in 3D yet).
if (straight) {
const w = widthLayout(B * 1000);
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + 125 * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
const cover = 0.1;
// Top rows C1–C6 (코핑철근.md §5.1, §5.3): counted from the left, the even (1-based) positions are cycle 1 (C1–C3),
// the odd cycle 2 (C4–C6). In 0-based j that is odd j → cycle 1. Each row turns down 472 at both ends, and the
// k-th row's ends sit (k − 1)·57.2 further in.
const topEl = m.copingRefTop;
const hookTop = MAIN.hook / 1000;
for (let k = 1; k <= 3; k++) {
const z = topEl - 0.127 - (k - 1) * 0.057;
const xk = cover + ((k - 1) * MAIN.layHorizontal) / 1000;
xs.forEach((n, j) => {
const name = `C${k + (j % 2 === 1 ? 0 : 3)}`;
const pts: [number, number, number][] = [
[xk, z - hookTop, n],
[xk, z, n],
[L - xk, z, n],
[L - xk, z - hookTop, n],
];
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
// 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 - 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;
const left: [number, number, number][] = [
[cover, topEl - 0.127, n],
[cover, z(cover), n],
[xL0, z(xL0), n],
[L / 2 + lap, z(L / 2 + lap), n],
];
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]) {
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.
const colS = m.columns.map((c) => c.s * 1000);
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
// l_cap = h − c) that sits 25.4 beside the U along the coping.
const top = topEl - 0.1;
const dShear = NOMINAL_DIAMETER.H25;
const sLeg = (B * 1000 - 2 * CAP.cover) / 5 / 1000;
const legN = (i: number) => -B / 2 + 0.1 + i * sLeg;
for (const [name, list] of loops) {
for (const x of list) {
const xm = mm(x);
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++) {
const a = legN(i);
const b = legN(i + 3);
// The three 폐합 stack to the right, so no U meets another loop's U or 삿갓 (SHEAR_LOOP_STEP).
const xu = xm + mm(i * SHEAR_LOOP_STEP);
const xc = xu + mm(dShear);
addBar(name, 0, [xu, bot, a], [xu, bot, b], dShear, COLOR.shear);
addBar(name, 0, [xu, bot, a], [xu, top, a], dShear, COLOR.shear);
addBar(name, 0, [xu, bot, b], [xu, top, b], dShear, COLOR.shear);
addBar(name, 0, [xc, top, a], [xc, top, b], dShear, COLOR.shear);
addBar(name, 0, [xc, top, a], [xc, capBot, a], dShear, COLOR.shear);
addBar(name, 0, [xc, top, b], [xc, capBot, b], dShear, COLOR.shear);
}
}
}
// Side horizontal bars C12 / C13 (코핑철근.md §6.2): per row, two 일자 at the side faces from x_w to L − x_w,
// 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 levels = sideRowLevels(m.levelBottom, m.endBottom, m.copingRefTop);
const side = copingSideBars(L * 1000, levels.zBottom, levels.zBreak, m.copingGeom, levels.zTop);
for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) {
for (const r of rows) {
const xw = mm(r.xw);
addBar(name, 0, [xw, r.z, -sideFace], [L - xw, r.z, -sideFace], dSide, COLOR.shear);
addBar(name, 0, [xw, r.z, sideFace], [L - xw, r.z, sideFace], dSide, COLOR.shear);
const zh = r.z + mm(dSide);
for (const x0 of [xw, L - xw]) {
const leg = mm(SIDE_HOOK_LEG) * Math.sign(L / 2 - x0);
addBar(name, 0, [x0, zh, -sideFace], [x0, zh, sideFace], dSide, COLOR.shear);
addBar(name, 0, [x0, zh, -sideFace], [x0 + leg, zh, -sideFace], dSide, COLOR.shear);
addBar(name, 0, [x0, zh, sideFace], [x0 + leg, zh, sideFace], dSide, COLOR.shear);
}
}
}
// C14 ⊓ (코핑철근.md §8): across the width at each x, both ends down 15.5·d_b.
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, 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).
if (b.legs) {
addBar('C14', 0, [xm, b.z - c14Hook, c14Na], [xm, b.z, c14Na], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C14', 0, [xm, b.z, c14Nb], [xm, b.z - c14Hook, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
}
}
// C15 (거꾸로 된 U) and C16 (L형) on the stair (코핑철근.md §7): 11 bars each across the width.
const stairs = stairBars(L * 1000, B * 1000, topEl, mCoping);
for (const s of stairs.c15) {
const n = mm(s.n);
const ld = mm(s.leg);
const x0 = mm(s.x0);
const x1 = mm(s.x1);
addBar('C15', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C15', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C15', 0, [x1, s.z, n], [x1, s.z - ld, n], NOMINAL_DIAMETER.H19, COLOR.coping);
}
for (const s of stairs.c16) {
const n = mm(s.n);
const ld = mm(s.leg);
const x0 = mm(s.x0);
const x1 = mm(s.x1);
addBar('C16', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C16', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
}
}
// 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
const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => {
const N = 36;
for (let i = 0; i < N; i++) {
const a0 = (2 * Math.PI * i) / N;
const a1 = (2 * Math.PI * (i + 1)) / N;
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) {
const sc = c.s;
const base = c.bottom;
const rt = mm(col.tieRadius);
for (let i = 0; i < COLUMN_P1.nDiv; i++) {
const th = (2 * Math.PI * i) / COLUMN_P1.nDiv;
const r1 = mm(col.r1);
addBar('P1', c.k, [sc + r1 * Math.cos(th), base + mm(zBot), r1 * Math.sin(th)], [sc + r1 * Math.cos(th), base + mm(col.zTop1), r1 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column);
// 90° bottom hook of P1, outward (기둥철근.md §3.2, Q-C5): the 12·d tail is drawn, the bend is approximated by the tail's start.
const tail = mm(12 * NOMINAL_DIAMETER.S32);
const rOut = r1 + tail;
addBar('P1', c.k, [sc + r1 * Math.cos(th), base + mm(zBot), r1 * Math.sin(th)], [sc + rOut * Math.cos(th), base + mm(zBot), rOut * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column);
const r2 = mm(col.r2);
addBar('P2', c.k, [sc + r2 * Math.cos(th), base + mm(zBot), r2 * Math.sin(th)], [sc + r2 * Math.cos(th), base + mm(col.zTop2), r2 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column);
}
for (const z of col.p3) ring('P3', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19);
for (const z of col.p5) ring('P5', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19);
for (const z of col.p4) ring('P4', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19);
// Main bar couplers (D32, L150 Ø60, 4단 × 46줄 × 2열 = 368개): P1 at col.couplerP1, P2 at col.couplerP2 (+150 mm)
const r1 = mm(col.r1);
const r2 = mm(col.r2);
for (let i = 0; i < COLUMN_P1.nDiv; i++) {
const th = (2 * Math.PI * i) / COLUMN_P1.nDiv;
const cosTh = Math.cos(th);
const sinTh = Math.sin(th);
// P1 couplers (184)
for (const z of col.couplerP1) {
const cx = sc + r1 * cosTh;
const cz = r1 * sinTh;
const cy = base + mm(z);
addBar('주철근 커플러', c.k, [cx, cy - 0.075, cz], [cx, cy + 0.075, cz], 60, COLOR.couplerMain);
}
// P2 couplers (184, shifted +150 mm)
for (const z of col.couplerP2) {
const cx = sc + r2 * cosTh;
const cz = r2 * sinTh;
const cy = base + mm(z);
addBar('주철근 커플러', c.k, [cx, cy - 0.075, cz], [cx, cy + 0.075, cz], 60, COLOR.couplerMain);
}
}
// Tie couplers (D19, L100 Ø40, 총 35개): P5 1개 (19개) · P4 2개 (16개)
col.p5.forEach((z, j) => {
const th = (2 * Math.PI * (j * 7 + 0.5)) / COLUMN_P1.nDiv;
const cx = sc + rt * Math.cos(th);
const cz = rt * Math.sin(th);
const cy = base + mm(z);
const dx = 0.05 * -Math.sin(th);
const dz = 0.05 * Math.cos(th);
addBar('띠철근 커플러', c.k, [cx - dx, cy, cz - dz], [cx + dx, cy, cz + dz], 40, COLOR.couplerTie);
});
col.p4.forEach((z) => {
const angles = [(2 * Math.PI * 0.5) / COLUMN_P1.nDiv, (2 * Math.PI * 0.5) / COLUMN_P1.nDiv + Math.PI];
angles.forEach((th) => {
const cx = sc + rt * Math.cos(th);
const cz = rt * Math.sin(th);
const cy = base + mm(z);
const dx = 0.05 * -Math.sin(th);
const dz = 0.05 * Math.cos(th);
addBar('띠철근 커플러', c.k, [cx - dx, cy, cz - dz], [cx + dx, cy, cz + dz], 40, COLOR.couplerTie);
});
});
const hookP6 = mm(Math.round(hookLength('tie90', NOMINAL_DIAMETER.H16))); // 135 mm
const dP6 = NOMINAL_DIAMETER.H16; // 15.9 mm
const p6Angles = [0, 46.96, 90, 133.04];
for (const z of col.p6Levels) {
p6Angles.forEach((deg, i) => {
const a = (deg * Math.PI) / 180;
const xA = sc - rt * Math.cos(a);
const zA = -rt * Math.sin(a);
const xB = sc + rt * Math.cos(a);
const zB = rt * Math.sin(a);
// Vertical stacking offset: ±1.5 db, ±0.5 db to avoid interference at the column centre (Q-C15)
const zOffset = (i - 1.5) * dP6;
const yBar = base + mm(z + zOffset);
// Main crosstie across the diameter
addBar('P6', c.k, [xA, yBar, zA], [xB, yBar, zB], dP6, COLOR.tie);
// 90° hooks at both ends turning downward (135 mm, wrapping the tie)
addBar('P6', c.k, [xA, yBar, zA], [xA, yBar - hookP6, zA], dP6, COLOR.tie);
addBar('P6', c.k, [xB, yBar, zB], [xB, yBar - hookP6, zB], dP6, COLOR.tie);
});
}
}
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
for (const f of m.footings) {
const Lf = f.length;
const Wf = f.width;
const Hf = (f.top - f.bottom) * 1000;
const z = layers(Hf);
const gW = grid(Wf * 1000);
const gL = grid(Lf * 1000);
const posW = (j: number) => -Wf / 2 + mm(100 + gW.e + j * 125);
const posL = (i: number) => -Lf / 2 + mm(100 + gL.e + i * 125);
const sA = -Lf / 2 + 0.1;
const sB = Lf / 2 - 0.1;
const nA = -Wf / 2 + 0.1;
const nB = Wf / 2 - 0.1;
const bottom = f.bottom;
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) => {
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) => {
const at = (t: number, h: number): [number, number, number] => [f.s + t, h, n];
path(name, legTo === undefined ? [at(sA, y), at(sB, y)] : [at(sA, legTo), at(sA, y), at(sB, y), at(sB, legTo)], dMm);
};
// B5–B8: across, at L position s. Same rule, with the ends on the W faces (§6.2).
const across = (name: string, s: number, y: number, legTo: number | undefined, dMm: number) => {
const at = (t: number, h: number): [number, number, number] => [f.s + s, h, t];
path(name, legTo === undefined ? [at(nA, y), at(nB, y)] : [at(nA, legTo), at(nA, y), at(nB, y), at(nB, legTo)], dMm);
};
for (let j = 0; j <= gW.n; j++) {
const n = posW(j);
if (j % 2 === 1) {
// B1 ⊓ down, B2 ⊔ up to the B1 height, B3 ⊔ up. B1·B3 sit beside the B2 leg (+25.4 / +22.25, §5.3).
along('B1', n + mm(off.B1), bottom + mm(z.B1), bottom + mm(z.B1) - mm(legLength('S25')), NOMINAL_DIAMETER.S25);
along('B2', n, bottom + mm(z.B24), bottom + mm(z.B1), NOMINAL_DIAMETER.S25);
along('B3', n + mm(off.B3), bottom + mm(z.B3), bottom + mm(z.B3) + mm(legLength('S19')), NOMINAL_DIAMETER.S19);
} else {
along('B4', n, bottom + mm(z.B24), undefined, NOMINAL_DIAMETER.S25);
}
}
for (let i = 0; i <= gL.n; i++) {
const s = posL(i);
if (i % 2 === 1) {
// B5 ⊓ down, B6 ⊔ up to the B5 height, B7 ⊔ up. B5·B7 sit beside the B6 leg (+28.6 / +23.85, §6.3).
across('B5', s + mm(off.B5), bottom + mm(z.B5), bottom + mm(z.B5) - mm(legLength('S29')), NOMINAL_DIAMETER.S29);
across('B6', s, bottom + mm(z.B68), bottom + mm(z.B5), NOMINAL_DIAMETER.S29);
across('B7', s + mm(off.B7), bottom + mm(z.B7), bottom + mm(z.B7) + mm(legLength('S19')), NOMINAL_DIAMETER.S19);
} else {
across('B8', s, bottom + mm(z.B68), undefined, NOMINAL_DIAMETER.S29);
}
}
// B9 (확대기초철근 (1).md §9, rule applied): vertical H16 bars on the grid, outside the column zones, with 135° hooks.
// Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19).
const dB9 = NOMINAL_DIAMETER.H16;
const tail9 = (6 * dB9) / 1000;
const colX = m.columns.filter((c) => Math.abs(c.s - f.s) <= Lf / 2).map((c) => (c.s - f.s) * 1000);
const yB9Bot = bottom + mm(z.B24);
const yB9Top = bottom + mm(z.B5);
for (const b of b9Bars(Lf * 1000, Wf * 1000, colX).bars) {
const x = mm(b.x);
const n = mm(b.n);
const tailDir = x >= 0 ? -1 : 1;
addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing);
addBar('B9', 0, [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing);
addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing);
}
// 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) => {
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
const d1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19); // B10-1 depth (mm), on the W faces
const d2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19); // B10-2 depth (mm), on the L faces
const sEdge = Lf / 2 - mm(d2);
const nEdge = Wf / 2 - mm(d1);
const dB10 = mm(NOMINAL_DIAMETER.S19);
for (let j = 0; j < b10Levels(Hf); j++) {
const zj = bottom + mm(FOOTING_DEFAULTS.cBottom + j * FOOTING_DEFAULTS.sV);
for (const sg of [-1, 1]) {
// B10-2: across W at the L faces; its ends turn inward along s.
const sp = f.s + sg * sEdge;
polyline('B10-2', [[sp - sg * hookB10, zj, -nEdge], [sp, zj, -nEdge], [sp, zj, nEdge], [sp - sg * hookB10, zj, nEdge]], NOMINAL_DIAMETER.S19);
// B10-1: along s at the W faces, 19.1 above B10-2; its ends turn inward along n.
const np = sg * nEdge;
const zb = zj + dB10;
polyline('B10-1', [[f.s - sEdge, zb, np - sg * hookB10], [f.s - sEdge, zb, np], [f.s + sEdge, zb, np], [f.s + sEdge, zb, np - sg * hookB10]], NOMINAL_DIAMETER.S19);
}
}
// B11 (across W, spread along L) and B12 (along L, spread across W) at the assembly levels, 4s apart, 2s stagger per level.
const xsL = gridPositions(Lf * 1000);
const ysW = gridPositions(Wf * 1000);
const dAsm = assemblyEndOffset();
const nAsm = Wf / 2 - mm(d1 + dAsm);
const sAsm = Lf / 2 - mm(d2 + dAsm);
assemblyJs(Hf).forEach((jA, k) => {
const zA = bottom + mm(FOOTING_DEFAULTS.cBottom + jA * FOOTING_DEFAULTS.sV);
const zB = zA + dB10;
const i0 = k % 2 === 0 ? 0 : 2;
for (let iL = i0; iL < xsL.length; iL += 4) {
const x = f.s + mm(xsL[iL]);
polyline('B11', [[x, zA - hookAsm, -nAsm], [x, zA, -nAsm], [x, zA, nAsm], [x, zA - hookAsm, nAsm]], NOMINAL_DIAMETER.S16);
}
for (let iW = i0; iW < ysW.length; iW += 4) {
const n = mm(ysW[iW]);
polyline('B12', [[f.s - sAsm, zB - hookAsm, n], [f.s - sAsm, zB, n], [f.s + sAsm, zB, n], [f.s + sAsm, zB - hookAsm, n]], NOMINAL_DIAMETER.S16);
}
});
}
// Bearing blocks: S2 (upper, along s) and S1 (lower, along n), 8 bars each across the 1200 block.
// Positions from barsAcross (162.5 from the block edge, @125), bars a − 2·c_s long, legs 700 down at both ends (§3–5).
const across = barsAcross();
const half = (BLOCK_REBAR.dim - 2 * BLOCK_REBAR.cover) / 2000;
const legM = BLOCK_REBAR.legLength / 1000;
const offsets = Array.from({ length: across.count }, (_, i) => (across.firstEnd + i * across.spacing) / 1000 - BLOCK_REBAR.dim / 2000);
const dBar = NOMINAL_DIAMETER.H19;
// The bars are drawn in the block's own frame (a along its s side, c along its n side) and turned with the block.
for (const b of m.blocks) {
const z2 = b.top - BLOCK_REBAR.depthS2 / 1000;
const z1 = b.top - BLOCK_REBAR.depthS1 / 1000;
const P = (a: number, c: number, y: number): [number, number, number] => {
const [s, n] = blockFramePoint(b, a, c);
return [s, y, n];
};
for (const off of offsets) {
addBar('S2', b.k, P(-half, off, z2), P(half, off, z2), dBar, COLOR.block);
addBar('S2', b.k, P(-half, off, z2 - legM), P(-half, off, z2), dBar, COLOR.block);
addBar('S2', b.k, P(half, off, z2), P(half, off, z2 - legM), dBar, COLOR.block);
addBar('S1', b.k, P(off, -half, z1), P(off, half, z1), dBar, COLOR.block);
addBar('S1', b.k, P(off, -half, z1 - legM), P(off, -half, z1), dBar, COLOR.block);
addBar('S1', b.k, P(off, half, z1), P(off, half, z1 - legM), dBar, COLOR.block);
}
}
const show = (names: ReadonlySet<string> | null, isolate: boolean) => {
// 커플러 as a name selects every coupler bar.
const isOn = (key: string) => names === null || names.has(key) || (names.has('커플러') && key.includes('커플러'));
for (const [key, meshes] of byName) {
const on = isOn(key);
for (const mesh of meshes) {
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.transparent = !on;
mat.opacity = on ? 1 : 0.18;
mat.emissive.setHex(names !== null && on ? 0x6b5a00 : 0x000000);
mesh.visible = !isolate || on;
}
}
};
const colorOf = (mesh: THREE.Mesh, mode: RebarColorMode): number => {
const data = mesh.userData as { roleColor: number; dMm: number; member: RebarMemberKey };
if (mode === 'role') return data.roleColor;
if (mode === 'member') return MEMBER_COLORS[data.member];
const idx = diameterGroupIndex(data.dMm);
return idx < 0 ? OTHER_COLOR : DIAMETER_COLORS[idx];
};
const setColorMode = (mode: RebarColorMode) => {
for (const meshes of byName.values()) {
for (const mesh of meshes) (mesh.material as THREE.MeshStandardMaterial).color.copy(rebarColor(colorOf(mesh, mode)));
}
};
const legend = (mode: RebarColorMode) => {
const all = [...byName.values()].flat().map((mesh) => mesh.userData as { dMm: number; member: RebarMemberKey });
if (mode === 'role') return [];
if (mode === 'member') {
const present = new Set(all.map((d) => d.member));
return (Object.keys(MEMBER_LABELS) as RebarMemberKey[]).filter((k) => present.has(k)).map((k) => ({ label: MEMBER_LABELS[k], color: hex(MEMBER_COLORS[k]) }));
}
const present = new Set(all.map((d) => diameterGroupIndex(d.dMm)));
const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
};
return { group, byName, show, setColorMode, legend };
}