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']);
+ });
+});