diff --git a/src/main.ts b/src/main.ts index e7006f6..45a5219 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'); diff --git a/src/view/coping-rebar-svg.ts b/src/view/coping-rebar-svg.ts new file mode 100644 index 0000000..881f34e --- /dev/null +++ b/src/view/coping-rebar-svg.ts @@ -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 = { 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(``); + } + parts.push(`코핑 길이 ${lengthMm}`); + const h = (top - bottom) * 1000; + return `${parts.join('')}`; +} + +/** + * 단면 · 폐합 구성 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) => `${c.name}`); + return `${parts.join('')}`; +} diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index a992eba..094f2e3 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -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; + /** 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; @@ -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(); + 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(); @@ -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 }; } diff --git a/tests/coping-rebar-svg.test.ts b/tests/coping-rebar-svg.test.ts new file mode 100644 index 0000000..87d5818 --- /dev/null +++ b/tests/coping-rebar-svg.test.ts @@ -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(/ { + 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(/