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 { barDepths, barsAcross } from '../core/rebar/bearing-block'; import { blockFramePoint } from '../core/geometry'; import { INTEGRATED_BLOCK } from '../core/integrated-block'; 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. */ /** One bar in the local frame (x = along s, y = EL, z = across n), before the stretch matrix. For the 정면 projection (승인 2026-10-26). */ export interface BarRecord { name: string; k: number; pts: [number, number, number][]; dMm: number; } export interface RebarScene { group: THREE.Group; byName: Map; /** Every bar as recorded (same data as the meshes). */ records: BarRecord[]; /** 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 | 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 = { bearing: 0xe69f00, coping: 0x0072b2, column: 0x009e73, footing: 0xcc79a7, other: OTHER_COLOR }; const MEMBER_LABELS: Record = { 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(); const records: BarRecord[] = []; 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; records.push({ name, k, pts: pts.map((p) => [...p] as [number, number, number]), dMm }); 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) => { records.push({ name, k, pts: [[...a], [...b]], dMm }); 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). Each set has its own spacing (승인 2026-10-26). // Positions from barsAcross (the first bar at the side cover + first gap), legs of legLength down at both ends (§3–5). const br = m.blockRebar; const depth = barDepths(br); const legM = br.legLength / 1000; const half = (1200 - 2 * br.coverSide) / 2000; const offsetsOf = (spacing: number) => { const a = barsAcross(1200, spacing, br); return Array.from({ length: a.count }, (_, i) => (a.firstEnd + i * a.spacing) / 1000 - 0.6); }; const offsetsS2 = offsetsOf(br.spacingS2); const offsetsS1 = offsetsOf(br.spacingS1); 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) { if (m.integratedBlocks.length > 0) break; // 일체: drawn per pair below const z2 = b.top - depth.S2 / 1000; const z1 = b.top - depth.S1 / 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 offsetsS2) { 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); } for (const off of offsetsS1) { 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); } } // 일체 block (승인 2026-10-26): S2 runs along the 교각축 (1200) with the bars across the 2500 (교축); S1 runs along // the 2500 with the bars across the 1200. Each S2 bar sits under its own half's top (front: offset ≤ 0). for (const ib of m.integratedBlocks) { const k = m.blocks.find((b) => b.no === ib.frontNo && b.row === '전열')!.k; const frame = { s: ib.s, n: ib.n, rowSkewDeg: ib.rowSkewDeg }; const P = (a: number, c: number, y: number): [number, number, number] => { const [s, n] = blockFramePoint(frame, a, c); return [s, y, n]; }; const acrossN = barsAcross(INTEGRATED_BLOCK.length, br.spacingS2, br); const acrossS = barsAcross(INTEGRATED_BLOCK.depth, br.spacingS1, br); const halfS = (INTEGRATED_BLOCK.depth - 2 * br.coverSide) / 2000; const halfN = (INTEGRATED_BLOCK.length - 2 * br.coverSide) / 2000; const z1 = Math.min(ib.topFront, ib.topBack) - depth.S1 / 1000; for (const off of Array.from({ length: acrossN.count }, (_, i) => (acrossN.firstEnd + i * acrossN.spacing) / 1000 - INTEGRATED_BLOCK.length / 2000)) { const z2 = (off <= 1e-9 ? ib.topFront : ib.topBack) - depth.S2 / 1000; addBar('S2', k, P(-halfS, off, z2), P(halfS, off, z2), dBar, COLOR.block); addBar('S2', k, P(-halfS, off, z2 - legM), P(-halfS, off, z2), dBar, COLOR.block); addBar('S2', k, P(halfS, off, z2), P(halfS, off, z2 - legM), dBar, COLOR.block); } for (const off of Array.from({ length: acrossS.count }, (_, i) => (acrossS.firstEnd + i * acrossS.spacing) / 1000 - INTEGRATED_BLOCK.depth / 2000)) { addBar('S1', k, P(off, -halfN, z1), P(off, halfN, z1), dBar, COLOR.block); addBar('S1', k, P(off, -halfN, z1 - legM), P(off, -halfN, z1), dBar, COLOR.block); addBar('S1', k, P(off, halfN, z1), P(off, halfN, z1 - legM), dBar, COLOR.block); } } const show = (names: ReadonlySet | 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, records, show, setColorMode, legend }; }