From a18a15db7bcd9876e6fc35f79fb551412e85f829 Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 14:54:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20=EC=A0=95=EB=A9=B4=20dimension?= =?UTF-8?q?=20chains=20computed=20from=20the=203D=20bar=20positions=20(?= =?UTF-8?q?=EA=B8=B0=EB=91=A5=20=EC=A4=91=EC=8B=AC=20=3D=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chainDims groups the repeated gaps (n@pitch = total) and writes a single gap as its length. - Top chain: the 전단·띠 stations (C9 @250, C10 @300) from the coping ends. - Lower chain: the upper C14 bars measured from the column centre (0), so the 10@1000 span starts at the column centre. - Stations are the lowest s of their pieces; pieces within 160 mm are one station. - P1 gives the 시안 values: 300 · 18@250=4500 · 8@300=2400 · 18@250=4500 · 300 and 900 · 10@1000=10000 · 900. Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 2 +- src/view/coping-rebar-svg.ts | 83 ++++++++++++++++++++++------------ tests/coping-rebar-svg.test.ts | 19 +++++++- 3 files changed, 72 insertions(+), 32 deletions(-) diff --git a/src/main.ts b/src/main.ts index 45a5219..f44e97a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1092,7 +1092,7 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { wrap.appendChild(row); const fig = el('figure', undefined, 'ground-fig'); if (copingView === 'front') { - fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000); + fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000, pier.model.columns[0]?.s ?? 0); fig.appendChild(el('figcaption', '정면 · 교축 방향에서 본 코핑 배근 (3D 철근과 같은 데이터)', 'muted small')); } else { const at = copingStation ?? L / 2; diff --git a/src/view/coping-rebar-svg.ts b/src/view/coping-rebar-svg.ts index 4f47b4f..d7cd0f3 100644 --- a/src/view/coping-rebar-svg.ts +++ b/src/view/coping-rebar-svg.ts @@ -31,17 +31,48 @@ const CALLOUTS: { label: string; names: string[]; where: 'left-top' | 'left' | ' { label: 'C16', names: ['C16'], where: 'top' }, ]; -/** The 시안 길이 치수 along the top (mm); the parts sum to the coping length for P1 (300 + 4500 + 2400 + 4500 + 300 = 12000). */ -const TOP_DIMS: { text: string; len: number }[] = [ - { text: '300', len: 300 }, - { text: '18@250=4500', len: 4500 }, - { text: '8@300=2400', len: 2400 }, - { text: '18@250=4500', len: 4500 }, - { text: '300', len: 300 }, -]; +/** + * A dimension chain from bar positions (승인 2026-10-26): the gaps between the sorted positions, grouped where the gap + * repeats (n@pitch = total). A gap that does not repeat is written as its own length. All in mm. + */ +export function chainDims(positions: number[], startMm: number, endMm: number): { text: string; len: number; from: number; to: number }[] { + const pts = [...new Set([startMm, ...positions.map((p) => Math.round(p)), endMm])].sort((a, b) => a - b); + const gaps: { from: number; to: number; len: number }[] = []; + for (let i = 0; i + 1 < pts.length; i++) gaps.push({ from: pts[i], to: pts[i + 1], len: pts[i + 1] - pts[i] }); + const out: { text: string; len: number; from: number; to: number }[] = []; + let i = 0; + while (i < gaps.length) { + let j = i; + while (j + 1 < gaps.length && Math.abs(gaps[j + 1].len - gaps[i].len) <= 1) j++; + const n = j - i + 1; + const total = gaps.slice(i, j + 1).reduce((s, g) => s + g.len, 0); + const pitch = Math.round(gaps[i].len); + out.push({ text: n === 1 ? `${pitch}` : `${n}@${pitch}=${total}`, len: total, from: gaps[i].from, to: gaps[j].to }); + i = j + 1; + } + return out; +} + +/** + * Bar positions (mm, along the coping): one per bar, its mean s. A loop or a cross bar sits at one station, so its + * points share the same s apart from the bends; the bends are not positions of their own. + */ +function positionsOf(bars: BarRecord[]): number[] { + // The pieces of one station are up to ~130 mm long, so the positions within 160 mm of each other are one station + // (the closest real spacing in the chains is 250 mm). + const vals = bars.map((r) => (r.pts.reduce((s, p) => s + p[0], 0) / r.pts.length) * 1000).sort((x, y) => x - y); + const groups: number[][] = []; + for (const v of vals) { + const g = groups[groups.length - 1]; + if (g && v - g[0] <= 160) g.push(v); + else groups.push([v]); + } + // The station is the lowest s of its pieces (the stirrup starts there, its pieces run on from it). + return groups.map((g) => Math.round(g[0])); +} /** 정면: the bars seen from the 교축 direction, with the bar-group bubbles and the length dimensions (시안 ③). */ -export function copingFrontSvg(records: BarRecord[], lengthMm: number): string { +export function copingFrontSvg(records: BarRecord[], lengthMm: number, columnSm = 0): string { const bars = records.filter(isCoping); const els = bars.flatMap((r) => r.pts.map((p) => p[1])); const top = Math.max(...els); @@ -62,30 +93,24 @@ export function copingFrontSvg(records: BarRecord[], lengthMm: number): string { parts.push(``); } - // Top dimension line: the parts of the 시안, only when they add up to the coping length. - const dimTotal = TOP_DIMS.reduce((s, d) => s + d.len, 0); + // First chain (the top line): the 전단·띠 positions of C9 (@250) and C10 (@300) along the coping, ends to the bar extents. + const loops = bars.filter((r) => /^C(9|10|11)$/.test(r.name)); + const topChain = chainDims(positionsOf(loops), 0, lengthMm); const dimY = minY - 1100; - if (Math.abs(dimTotal - lengthMm) < 1) { - let x = minX; - for (const d of TOP_DIMS) { - const x2 = x + d.len; - parts.push(`${d.text}`); - x = x2; - } + for (const d of topChain) { + parts.push(`${d.text}`); } - // Second line (시안): the column-centre span 900 · 10@1000=10000 · 900, from the design values. + // Second chain (the lower line): the upper C14 bars, measured from the column centre (0). + const c14 = bars.filter((r) => r.name === 'C14'); + const c14Top = Math.max(...c14.flatMap((r) => r.pts.map((p) => p[1]))); + const c14Upper = c14.filter((r) => r.pts.every((p) => p[1] >= c14Top - 0.3)); + const col = columnSm * 1000; + const lowerChain = chainDims(positionsOf(c14Upper).map((v) => v - col), minX - col, maxX - col); const dimY2 = dimY - 600; - const LOWER: { text: string; len: number }[] = [ - { text: '900', len: 900 }, - { text: '10@1000=10000 (기둥 중심부터)', len: 10000 }, - { text: '900', len: 900 }, - ]; - let x2 = minX; - for (const d of LOWER) { - const xe = x2 + d.len; - parts.push(`${d.text}`); - x2 = xe; + for (const d of lowerChain) { + parts.push(`${d.text}`); } + parts.push(`기둥 중심`); parts.push(`코핑 길이 ${lengthMm}`); // Bubbles: one per bar group, a leader from the bubble to the middle of the group's bars. diff --git a/tests/coping-rebar-svg.test.ts b/tests/coping-rebar-svg.test.ts index f9143de..5fdf202 100644 --- a/tests/coping-rebar-svg.test.ts +++ b/tests/coping-rebar-svg.test.ts @@ -1,7 +1,7 @@ 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 { chainDims, copingBarGroup, copingFrontSvg, copingSectionSvg } from '../src/view/coping-rebar-svg'; import { loadP1 } from './helpers'; const model = buildPier(loadP1()); @@ -59,8 +59,23 @@ describe('정면 번호 풍선과 치수 (승인 2026-10-26, 시안 ③)', () => describe('정면 두 번째 치수선과 범례 (승인 2026-10-26, 시안)', () => { const svg = copingFrontSvg(scene.records, model.copingL * 1000); it('writes the column-centre span 10@1000=10000 and the legend', () => { - expect(svg).toContain('10@1000=10000 (기둥 중심부터)'); + expect(svg).toContain('10@1000=10000'); + expect(svg).toContain('기둥 중심'); expect(svg).toContain('주철근 (C1–C8)'); expect(svg).toContain('전단·띠 (C9–C11)'); }); }); + +describe('정면 치수는 3D 철근 위치에서 계산 (승인 2026-10-26)', () => { + it('the chains come from the bar positions and match the 시안 values', () => { + const svg = copingFrontSvg(scene.records, model.copingL * 1000, model.columns[0].s); + const texts = [...svg.matchAll(/font-size="150"[^>]*>([^<]*) x[1]); + expect(texts).toEqual(['300', '18@250=4500', '8@300=2400', '18@250=4500', '300', '900', '10@1000=10000', '900', '기둥 중심']); + }); +}); + +describe('chainDims', () => { + it('groups repeated gaps and writes a single gap as its length', () => { + expect(chainDims([300, 550, 800, 1100], 0, 1400).map((d) => d.text)).toEqual(['300', '2@250=500', '2@300=600']); + }); +});