Files
Pier_v1/src/view/footing-section-svg.ts
T
ypshimandClaude Haiku 5.5 99db6a1f52 Revert "feat(rebar): B1–B4와 B5–B8 방향 교환 (B1–B4는 W 방향, B5–B8은 L 방향)"
이 교환은 9,350 방향이 L이라는 가정에서 적용됐으나, 단면 삽도(B5–B8 선, 교각축 직각 단면)와 재료표가 원래 배치(B1–B4 L 방향, B5–B8 W 방향)와 맞는 것으로 확인되어 되돌림.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-27 09:48:05 +09:00

197 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PierModel } from '../core/geometry';
import { NOMINAL_DIAMETER } from '../core/rebar/common';
import { assemblyGridIndices, assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, sideDepth } from '../core/rebar/footing';
import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings';
/**
* 확대기초 · 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추천안 1·2단계). A cut across the footing width: horizontal = W, vertical = height
* from the 하면 at true scale. Every mark comes from the same functions as the rebar table and the 3D view:
* - B5–B8 teal lines (bars along W), B1–B4 orange points on the grid along W, B9 purple lines per row,
* - B10-1 green points at both W faces per level, B11 grey dashed lines per assembly level, B12 grey points on its W grid.
* Legs are not drawn; the B6 leg length is noted in the figure. The column and the piles are shown as context.
* Each mark carries data-bar="<철근 번호>" so a bubble can highlight its bars (철근표 강조와 같은 방식).
*/
const COLOR = {
outline: '#55606E',
b5: '#0F5C66',
b14: '#B4531F',
b9: '#6B3FA0',
b10: '#2E7D32',
b11: '#55606E',
b12: '#55606E',
dim: '#B42318',
column: '#B08D57',
pile: '#8898aa',
};
const W_PX = 380;
const X0 = 60;
/** Height of the 하면 (z = 0) in the view; the figure is drawn at true scale (kY = kX). */
const Y0 = 230;
export function footingSectionSvg(model: PierModel): string {
const foot = model.footings[0];
if (!foot) return '<svg viewBox="0 0 600 430" width="600" height="430" role="img" aria-label="확대기초 없음"><text x="20" y="180" font-size="12">확대기초 없음</text></svg>';
const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR;
const L = foot.length * 1000;
const W = foot.width * 1000;
const H = (foot.top - foot.bottom) * 1000;
const kX = W_PX / W;
const kY = kX;
const cx = X0 + W_PX / 2;
const x = (mm: number) => cx + mm * kX;
const y = (z: number) => Y0 - z * kY;
const z = layers(H, fs);
const parts: string[] = [];
const line = (cls: string, bar: string, x1: number, x2: number, yy: number, color: string, width = 1.4, dash = '') =>
`<line class="${cls}" data-bar="${bar}" x1="${x1.toFixed(1)}" x2="${x2.toFixed(1)}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}" stroke="${color}" stroke-width="${width}"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`;
const dot = (cls: string, bar: string, xx: number, yy: number, color: string, r = 2.6) =>
`<circle class="${cls}" data-bar="${bar}" cx="${xx.toFixed(1)}" cy="${yy.toFixed(1)}" r="${r}" fill="${color}"/>`;
const vline = (cls: string, bar: string, xx: number, y1: number, y2: number, color: string) =>
`<line class="${cls}" data-bar="${bar}" x1="${xx.toFixed(1)}" x2="${xx.toFixed(1)}" y1="${y1.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${color}" stroke-width="1"/>`;
// Dimension and note text has a white halo (paint-order stroke) so that leader lines under it stay readable.
const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) =>
`<text x="${xx.toFixed(1)}" y="${yy.toFixed(1)}" font-size="10" font-family="JetBrains Mono, monospace" fill="${fill}" stroke="#fff" stroke-width="3" paint-order="stroke" text-anchor="${anchor}">${s}</text>`;
const tick = (xx: number, yy: number) => `<line x1="${(xx - 3).toFixed(1)}" x2="${(xx + 3).toFixed(1)}" y1="${(yy + 3).toFixed(1)}" y2="${(yy - 3).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`;
// Context: the column (centred on W, above the footing) and the piles (below, one stub per distinct W position).
const colD = model.columnDiameterMm;
parts.push(`<rect class="column" x="${x(-colD / 2).toFixed(1)}" y="${y(H) - 36}" width="${(colD * kX).toFixed(1)}" height="36" fill="#F2E8D5" stroke="${COLOR.column}" stroke-width="0.8"/>`);
const piles = model.piles.filter((p) => Math.abs(p.s - foot.s) <= foot.length / 2 + 1e-6);
for (const n of [...new Set(piles.map((p) => Math.round(p.n * 1000)))]) {
const d = (piles.find((p) => Math.round(p.n * 1000) === n)?.diameter ?? 0.6) * 1000;
parts.push(`<rect class="pile" x="${(x(n) - d * kX / 2).toFixed(1)}" y="${Y0}" width="${Math.max(3, d * kX).toFixed(1)}" height="26" fill="${COLOR.pile}" stroke="#3b4656" stroke-width="0.6"/>`);
}
// Footing outline (W × H) and the 하면 line.
parts.push(`<rect x="${x(-W / 2).toFixed(1)}" y="${y(H).toFixed(1)}" width="${(W * kX).toFixed(1)}" height="${(H * kY).toFixed(1)}" fill="none" stroke="${COLOR.outline}" stroke-width="1.2"/>`);
parts.push(line('base', '', x(-W / 2), x(W / 2), y(0), COLOR.outline, 1));
// B5–B8: teal lines along W between the covers. B5 at the top, B6 and B8 at the 하면 1단, B7 at the 하면 2단.
const xa = x(-W / 2 + fs.cSide);
const xb = x(W / 2 - fs.cSide);
parts.push(line('b5', 'B5', xa, xb, y(z.B5), COLOR.b5, 1.6));
parts.push(line('b5', 'B6', xa, xb, y(z.B68), COLOR.b5, 1.2));
parts.push(line('b5', 'B7', xa, xb, y(z.B7), COLOR.b5, 1.2));
parts.push(line('b5', 'B8', xa, xb, y(z.B68), COLOR.b5, 1.2));
// B1–B4 on the grid along W (교각축, orange points): odd grid positions carry B1, B2 and B3; even carry B4 (as bottomBars).
const xs = gridPositions(W, fs, 'sAxis');
xs.forEach((mm, i) => {
const xx = x(mm);
if (i % 2 === 1) {
parts.push(dot('b1', 'B1', xx, y(z.B1), COLOR.b14));
parts.push(dot('b2', 'B2', xx, y(z.B24), COLOR.b14));
parts.push(dot('b3', 'B3', xx, y(z.B3), COLOR.b14));
} else {
parts.push(dot('b4', 'B4', xx, y(z.B24), COLOR.b14));
}
});
// Outermost columns (승인 2026-10-26, 추천): B1·B2·B3·B4 all sit in the first and last grid column (확대기초철근 (1).md §3, §5).
// The missing ones are drawn as 'ext' points (they do not change the table counts). Their top (B1) and bottom (B2·B4) runs extend
// to the side cover c_s, and a leg line at the cover runs from the 하면 1단 (B2) up to the B1 height.
const xSideL = x(-W / 2 + fs.cSide);
const xSideR = x(W / 2 - fs.cSide);
const last = xs.length - 1;
for (const i of [0, last]) {
const drawnHere = i % 2 === 1 ? ['B1', 'B2', 'B3'] : ['B4'];
const xx = x(xs[i]);
for (const [name, zz] of [['B1', z.B1], ['B2', z.B24], ['B3', z.B3], ['B4', z.B24]] as const) {
if (!drawnHere.includes(name)) parts.push(dot('ext', name, xx, y(zz), COLOR.b14));
}
const xSide = i === 0 ? xSideL : xSideR;
parts.push(line('ext', 'B1', xx, xSide, y(z.B1), COLOR.b14, 1));
parts.push(line('ext', 'B2', xx, xSide, y(z.B24), COLOR.b14, 1));
// The B2 side leg (up to the B1 height at the side cover) is drawn heavier, so the corner reads as one run (승인 2026-10-26).
parts.push(`<line class="leg" data-bar="B2" x1="${xSide.toFixed(1)}" x2="${xSide.toFixed(1)}" y1="${y(z.B1).toFixed(1)}" y2="${y(z.B24).toFixed(1)}" stroke="${COLOR.b14}" stroke-width="1.8"/>`);
}
// B9: one purple vertical line per row along W (the rows of b9Bars).
const b9 = b9Bars(L, W, [], 2000, fs);
for (const n of [...new Set(b9.bars.map((b) => b.n))]) parts.push(vline('b9', 'B9', x(n), y(z.B24), y(z.B5), COLOR.b9));
// B10-1: green points at both W faces, one per level above the lowest (8 of 9 levels), at the side depth of the table.
const levels = b10Levels(H, fs);
const dB1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19, fs);
// The lowest level is removed (승인 2026-10-26): the 하면 1단 B2·B4 take its place at the corner.
for (let j = 1; j < levels; j++) {
const zj = fs.cBottom + j * fs.sV;
parts.push(dot('b10', 'B10-1', x(-W / 2 + dB1), y(zj), COLOR.b10, 2.2));
parts.push(dot('b10', 'B10-1', x(W / 2 - dB1), y(zj), COLOR.b10, 2.2));
}
// B11: grey dashed lines per assembly level. B12: grey points on its W grid, every k·s, on the even grid (1단계 규칙).
const js = assemblyJs(H, fs);
const dB10 = NOMINAL_DIAMETER.S19;
js.forEach((j, idx) => {
const zA = fs.cBottom + j * fs.sV;
parts.push(line('b11', 'B11', xa, xb, y(zA), COLOR.b11, 1.2, '6 3'));
const xsB12 = gridPositions(W, fs, 'sAxis');
for (const i of assemblyGridIndices(xsB12.length - 1, fs.kB12, idx)) parts.push(dot('b12', 'B12', x(xsB12[i]), y(zA + dB10), COLOR.b12, 2.2));
});
// Dimensions: the width line with ticks and the grid string, and the right-hand heights.
const g = grid(W, fs, 'sAxis');
const yDim = Y0 + 40;
parts.push(`<line x1="${x(-W / 2).toFixed(1)}" x2="${x(W / 2).toFixed(1)}" y1="${yDim}" y2="${yDim}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
parts.push(tick(x(-W / 2), yDim) + tick(x(W / 2), yDim));
// Edge covers sit outside the dimension ends; the grid pieces sit on the first row, spread apart so they do not run together.
parts.push(text(x(-W / 2) - 5, yDim + 14, `${fs.cSide}`, 'end', COLOR.dim));
parts.push(text(x(W / 2) + 5, yDim + 14, `${fs.cSide}`, 'start', COLOR.dim));
parts.push(text(x(-W / 2 + fs.cSide) + 2, yDim + 14, `${g.e.toFixed(1)}`, 'start', COLOR.dim));
parts.push(text(x(W / 2 - fs.cSide) - 2, yDim + 14, `${g.e.toFixed(1)}`, 'end', COLOR.dim));
// The grid string sits right of centre, so the bottom leaders (at the centre and the two sides) do not cross it.
parts.push(text(cx + 150, yDim - 6, `${g.n}@${fs.sAxis}=${g.G}`, 'middle', COLOR.dim));
parts.push(text(cx, 20, `교각축 직각 단면 · 폭 ${W.toFixed(0)}`, 'middle', '#1F2937'));
const xr = X0 + W_PX + 18;
parts.push(`<line x1="${xr}" x2="${xr}" y1="${y(H).toFixed(1)}" y2="${y(0).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
parts.push(tick(xr, y(H)) + tick(xr, y(0)));
parts.push(text(xr + 6, y(H) + 12, `${fs.cTop}`, 'start', COLOR.dim));
parts.push(text(xr + 6, (y(H) + y(0)) / 2, `${levels - 1}@${fs.sV}=${(levels - 1) * fs.sV}`, 'start', COLOR.dim));
parts.push(text(xr + 6, y(0) - 4, `${fs.cBottom}`, 'start', COLOR.dim));
// B6 leg note in the right margin, with a leader to the B6 leg's end at the B6 height (B6 다리 → B5 높이, from the 하면 1단 up to B5).
const yNote = y(H) - 14;
parts.push(`<line class="b6note" x1="${(xr + 4).toFixed(1)}" x2="${xb.toFixed(1)}" y1="${(yNote + 4).toFixed(1)}" y2="${y(z.B68).toFixed(1)}" stroke="${COLOR.b5}" stroke-width="0.7"/>`);
parts.push(text(xr + 6, yNote, 'B6 다리 → B5', 'start', COLOR.b5));
parts.push(text(xr + 6, yNote + 12, `높이 ${(z.B5 - z.B68).toFixed(0)}`, 'start', COLOR.b5));
// Bubbles outside the outline, spread apart, each with a leader to its bar (the leader carries the same data-bar, so it highlights too).
const bubble = (name: string, bx: number, by: number, tx: number, ty: number) => {
const edgeY = by < ty ? by + 11 : by - 11;
return `<g class="bubble" data-bubble="${name}"><line class="leader" data-bar="${name}" x1="${bx.toFixed(1)}" x2="${tx.toFixed(1)}" y1="${edgeY.toFixed(1)}" y2="${ty.toFixed(1)}" stroke="${COLOR.outline}" stroke-width="0.7"/><circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="11" fill="#fff" stroke="${COLOR.outline}"/><text x="${bx.toFixed(1)}" y="${(by + 3).toFixed(1)}" font-size="9" text-anchor="middle" fill="${COLOR.outline}">${name}</text></g>`;
};
const yTop = 40;
const yBot = Y0 + 100;
// Each bottom bubble sits straight below its own bar (B2 left of centre, B4 at centre, B3 right of centre) on a bar of the right parity.
const nearest = (target: number, parity: number) => {
let best = -1;
xs.forEach((mm, i) => {
if (i % 2 === parity && (best < 0 || Math.abs(mm - target) < Math.abs(xs[best] - target))) best = i;
});
return best;
};
const iB2 = nearest(-1930, 1);
const iB4 = nearest(0, 0);
const iB3 = nearest(1930, 1);
const zB11 = js.length > 0 ? fs.cBottom + js[0] * fs.sV : fs.cBottom;
parts.push(bubble('B5', xa + 12, yTop, xa + 12, y(z.B5)));
parts.push(bubble('B1', x(xs[1]) + 60, yTop, x(xs[1]), y(z.B1)));
parts.push(bubble('B11', xb - 20, yTop, xb, y(zB11)));
parts.push(bubble('B10-1', X0 - 18, y(fs.cBottom + (levels - 1) * fs.sV / 2), X0 + 2, y(fs.cBottom + (levels - 1) * fs.sV / 2)));
parts.push(bubble('B2', x(xs[iB2]), yBot, x(xs[iB2]), y(z.B24)));
parts.push(bubble('B4', x(xs[iB4]), yBot, x(xs[iB4]), y(z.B24)));
parts.push(bubble('B3', x(xs[iB3]), yBot, x(xs[iB3]), y(z.B3)));
// Legend (선 모양별, 삽도 기준) and the units note.
const yLeg = 352;
parts.push(`<line x1="20" x2="44" y1="${yLeg}" y2="${yLeg}" stroke="${COLOR.b5}" stroke-width="1.6"/>${text(50, yLeg + 3, '주철근 (B5–B8)', 'start', COLOR.outline)}`);
parts.push(`<circle cx="180" cy="${yLeg}" r="2.6" fill="${COLOR.b14}"/>${text(188, yLeg + 3, '직각·보조 (B1–B4)', 'start', COLOR.outline)}`);
parts.push(`<line x1="330" x2="330" y1="${yLeg - 8}" y2="${yLeg + 8}" stroke="${COLOR.b9}" stroke-width="1"/>${text(338, yLeg + 3, '전단·띠 (B9)', 'start', COLOR.outline)}`);
parts.push(`<circle cx="20" cy="${yLeg + 22}" r="2.2" fill="${COLOR.b10}"/>${text(28, yLeg + 25, '측면 수평 (B10-1 · B10-2)', 'start', COLOR.outline)}`);
parts.push(`<line x1="250" x2="274" y1="${yLeg + 22}" y2="${yLeg + 22}" stroke="${COLOR.b11}" stroke-width="1.2" stroke-dasharray="6 3"/>${text(280, yLeg + 25, '조립 (B11·B12)', 'start', COLOR.outline)}`);
parts.push(text(20, yLeg + 48, '치수 mm · 높이 방향은 실제 비율 · 다리 생략 (B6 다리는 주석) · 기준: 확대기초 하면 0', 'start', COLOR.outline));
// The whole drawing shifts down 20 px so the top bubbles are not clipped.
return `<svg viewBox="0 0 600 430" width="600" height="430" role="img" aria-label="확대기초 교각축 직각 단면"><g transform="translate(0 20)">${parts.join('')}</g></svg>`;
}