feat(rebar): coping front view and 단면·폐합 section from the 3D bar records (승인 2026-10-26)

- rebar3d records every bar (name, stretch, local points, diameter) next to its
  mesh, so the 2D drawings use the same data as the 3D view.
- view/coping-rebar-svg.ts: the 정면 (along the coping length, EL vertical) and
  the 단면·폐합 at a chosen station (the bars crossing it, C9–C11 loops included).
- ③ coping tab: 정면 / 단면·폐합 toggle and the station input.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 14:27:03 +09:00
1 parent 32e8dad7dc
commit 57f7c148ec
4 files changed
+157 -1

No files matched your search

+46
View File
@@ -24,6 +24,7 @@ import { bearingSectionSvg, type BearingDrawingRef } from './view/bearing-sectio
import { solplateDetailSvg, type SolplateDrawingRef } from './view/solplate-svg';
import { blockRebarSectionSvg } from './view/block-rebar-svg';
import { blockRebarPlanSvg } from './view/block-rebar-plan-svg';
import { copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg';
import { BLOCK_REBAR_RANGES } from './core/rebar/block-check';
import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block';
import { groundSectionSvg } from './view/ground-svg';
@@ -1069,6 +1070,50 @@ function renderBlockRebarSettings(box: HTMLDivElement, pier: Pier): void {
box.appendChild(wrap);
}
/** 코핑 배근 (승인 2026-10-26, 추천): 정면, and the 단면·폐합 at a station the user chooses (default mid-length). */
let copingView: 'front' | 'section' = 'front';
let copingStation: number | null = null;
function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void {
if (!pier.model || !rebar3d) return;
const L = pier.model.copingL;
const wrap = el('div', undefined, 'bearing-ref');
const row = el('div', undefined, 'opts');
for (const [v, label] of [['front', '정면'], ['section', '단면 · 폐합 구성']] as const) {
const b = el('button', label, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String(copingView === v));
b.addEventListener('click', () => {
copingView = v;
renderAll();
});
row.appendChild(b);
}
wrap.appendChild(row);
const fig = el('figure', undefined, 'ground-fig');
if (copingView === 'front') {
fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000);
fig.appendChild(el('figcaption', '정면 · 교축 방향에서 본 코핑 배근 (3D 철근과 같은 데이터)', 'muted small'));
} else {
const at = copingStation ?? L / 2;
const station = el('input');
station.type = 'number';
station.step = '0.1';
station.value = String(at);
station.addEventListener('change', () => {
copingStation = Number(station.value);
renderAll();
});
const label = el('label', '단면 위치 s (m, 코핑 시작에서) ');
label.appendChild(station);
wrap.appendChild(label);
fig.innerHTML = copingSectionSvg(rebar3d.records, at);
fig.appendChild(el('figcaption', `단면 · 폐합 구성 · s = ${at} m에서 자른 철근 (C9–C11 폐합 포함)`, 'muted small'));
}
wrap.appendChild(fig);
box.appendChild(wrap);
}
function renderRebar(): void {
const pier = activePier();
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
@@ -1103,6 +1148,7 @@ function renderRebar(): void {
const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember);
$('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier);
if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier);
const shown = rows.filter((r) => r.member === state.rebarMember);
box.appendChild(rebarSelectBar(shown.map((r) => r.name)));
const table = el('table', undefined, 'grid-table rebar-table');
+63
View File
@@ -0,0 +1,63 @@
import type { BarRecord } from './rebar3d';
/**
* 코핑 배근 · 정면 and 단면·폐합 (승인 2026-10-26, 추천: 3D 철근 기록을 그대로 투영).
* 정면: x = the coping length (s), y = EL. 단면: x = 교축 (n, the local z), y = EL, at one station s0: the bars that cross it.
* Colours by bar group: C1–C8 main, C9–C11 closed loops, C12–C13 sides, C14 points, C15–C16 steps.
*/
const COLOR: Record<string, string> = { main: '#0F5C66', loop: '#6B3FA0', side: '#2E7D32', step: '#B4531F', other: '#6B7280' };
export function copingBarGroup(name: string): keyof typeof COLOR {
const n = Number(/^C(\d+)/.exec(name)?.[1]);
if (!Number.isFinite(n)) return 'other';
if (n <= 8) return 'main';
if (n <= 11) return 'loop';
if (n <= 13) return 'side';
return 'step';
}
const isCoping = (r: BarRecord) => /^C\d/.test(r.name);
/** 정면: the bars seen from the 교축 direction. */
export function copingFrontSvg(records: BarRecord[], lengthMm: number): string {
const bars = records.filter(isCoping);
const els = bars.flatMap((r) => r.pts.map((p) => p[1]));
const top = Math.max(...els);
const bottom = Math.min(...els);
const Y = (el: number) => (top - el) * 1000;
const parts: string[] = [];
for (const r of bars) {
const pts = r.pts.map((p) => `${p[0] * 1000},${Y(p[1])}`).join(' ');
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR[copingBarGroup(r.name)]}" stroke-width="10"></polyline>`);
}
parts.push(`<text x="${lengthMm / 2}" y="${-200}" text-anchor="middle" font-size="260" fill="#18212B">코핑 길이 ${lengthMm}</text>`);
const h = (top - bottom) * 1000;
return `<svg viewBox="-400 -700 ${lengthMm + 800} ${h + 1000}" width="100%" style="max-width: 760px; height: auto" role="img" aria-label="코핑 배근 정면">${parts.join('')}</svg>`;
}
/**
* 단면 · 폐합 구성 at the station s0 (m): the bars that cross it, seen along the coping length.
* A bar crossing s0 is drawn at the crossing point (n, EL); a bar along the 교축 at s0 is drawn as its line.
*/
export function copingSectionSvg(records: BarRecord[], s0: number): string {
const crossings: { name: string; x: number; y: number; group: keyof typeof COLOR }[] = [];
const bars = records.filter(isCoping);
const els = bars.flatMap((r) => r.pts.map((p) => p[1]));
const top = Math.max(...els);
for (const r of bars) {
for (let i = 0; i < r.pts.length - 1; i++) {
const a = r.pts[i];
const b = r.pts[i + 1];
const lo = Math.min(a[0], b[0]);
const hi = Math.max(a[0], b[0]);
if (s0 < lo || s0 > hi) continue;
const t = hi - lo < 1e-9 ? 0 : (s0 - a[0]) / (b[0] - a[0]);
crossings.push({ name: r.name, x: a[2] + (b[2] - a[2]) * t, y: a[1] + (b[1] - a[1]) * t, group: copingBarGroup(r.name) });
}
}
const xs = crossings.map((c) => c.x * 1000);
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, 0);
const parts: string[] = crossings.map((c) => `<circle cx="${c.x * 1000}" cy="${(top - c.y) * 1000}" r="${c.group === 'step' ? 22 : 30}" fill="${COLOR[c.group]}"></circle><text x="${c.x * 1000 + 40}" y="${(top - c.y) * 1000 - 40}" font-size="140" fill="#18212B">${c.name}</text>`);
return `<svg viewBox="${minX - 800} -800 ${maxX - minX + 1600} ${(top - Math.min(...els)) * 1000 + 1600}" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="코핑 배근 단면 · 폐합 구성 at s = ${s0} m">${parts.join('')}</svg>`;
}
+14 -1
View File
@@ -17,9 +17,19 @@ import { stretchMatrix } from './scene';
* 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<string, THREE.Mesh[]>;
/** 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<string> | null, isolate: boolean): void;
@@ -60,6 +70,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
const mColumn = materialFor(common, byMember, 'column');
const group = new THREE.Group();
const byName = new Map<string, THREE.Mesh[]>();
const records: BarRecord[] = [];
const Ms = m.stretches.map((_, k) => stretchMatrix(m, k));
const L = m.copingL;
const B = m.copingB;
@@ -71,6 +82,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
*/
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<THREE.Vector3>();
@@ -110,6 +122,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
};
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;
@@ -564,5 +577,5 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
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 };
return { group, byName, records, show, setColorMode, legend };
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { buildRebar3D } from '../src/view/rebar3d';
import { copingBarGroup, copingFrontSvg, copingSectionSvg } from '../src/view/coping-rebar-svg';
import { loadP1 } from './helpers';
const model = buildPier(loadP1());
const scene = buildRebar3D(model);
describe('코핑 배근 정면 · 단면 (승인 2026-10-26)', () => {
it('the 3D bar records include the coping bars, the same data as the meshes', () => {
const coping = scene.records.filter((r) => /^C\d/.test(r.name));
expect(coping.length).toBeGreaterThan(0);
expect(scene.records.length).toBeGreaterThan(coping.length - 1);
});
it('the front view draws one polyline per coping bar', () => {
const coping = scene.records.filter((r) => /^C\d/.test(r.name));
const svg = copingFrontSvg(scene.records, model.copingL * 1000);
expect((svg.match(/<polyline/g) ?? []).length).toBe(coping.length);
});
it('groups the bars: C1–C8 main, C9–C11 loops, C12–C13 sides, C14–C16 steps', () => {
expect(copingBarGroup('C1')).toBe('main');
expect(copingBarGroup('C9')).toBe('loop');
expect(copingBarGroup('C12')).toBe('side');
expect(copingBarGroup('C15')).toBe('step');
});
it('the section at mid-length shows the bars that cross it', () => {
const svg = copingSectionSvg(scene.records, model.copingL / 2);
expect((svg.match(/<circle/g) ?? []).length).toBeGreaterThan(0);
});
});