fix(abut): 수축줄눈을 받침 사이 중간에 · 기본 개수 ceil(L/10) − 1 · 3D 선 표시

spec_abut §3 · §4.9 (QA-7 가정)와 맞지 않던 세 가지를 고친다.
- 위치: 등분점(1개면 L/2)이 아니라 가장 가까운 '받침 사이 중간'(= 교좌면 계단 경계). 이전에는 운곡교 교대
  5기 모두 줄눈이 가운데 받침 ③ 블럭을 지났다 (A1: 줄눈 6.099 · 받침 6.037). 이제 A1은 IP3에서 4.905 m
- 개수: 입력이 없으면 ceil(L/10) − 1 (길이에 따라 자동, 이전에는 1 고정). 입력 칸은 비우면 자동
- 3D: 벽체 전면 · 흉벽 전면 · 벽체 배면 · 흉벽 배면에 선으로 표시(저판은 연속이라 없음), '벽체·흉벽' 토글을 따름
- 공통 계산 core/abut/joints.ts를 ② 정면·배면 삽도, 교대 상세 요약, 입력 칸 안내, 3D가 함께 씀
- 테스트: 개수 규칙, A1 위치 = ②·③ 중간 = 계단 경계, 여러 개 · 받침 부족 시 등분, 3D 선 4면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-06 16:23:04 +09:00
1 parent 1fda705b75
commit bff1502ecf
8 files changed
+158 -13

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
/**
* 교대 수축줄눈 (spec_abut §3 · §4.9, plan_abut AA13, QA-7 가정). Pure.
*
* - 개수: 입력값, 없으면 ceil(벽체 길이 / 10 m) − 1 (0 이상).
* - 위치: 등분점 L·j / (n + 1)에서 가장 가까운 '받침 사이 중간'으로 옮긴다 (같은 거리면 s가 작은 쪽). 받침 사이 중간은
* 교좌면 계단 경계와 같은 자리라 줄눈이 받침블럭을 지나지 않는다. 받침이 2개 미만이거나 중간점이 모자라면 등분점.
* - 벽체 · 흉벽에만 두고 확대기초는 연속이다 (교대확대기초철근.md). 철근은 끊지 않는다 (spec_abut §6.1).
*/
/** 기본 개수: ceil(L / 10) − 1, L in m. */
export function defaultJointCount(lengthM: number): number {
return Math.max(0, Math.ceil(lengthM / 10 - 1e-9) - 1);
}
/** The count in use: the typed value, else the default from the length. */
export function jointCount(lengthM: number, typed?: number): number {
return typed ?? defaultJointCount(lengthM);
}
/** Joint positions s (m from IP1, sorted). `bearingS` = the bearings' s along the 교대축. */
export function jointPositions(lengthM: number, bearingS: number[], typed?: number): number[] {
const n = jointCount(lengthM, typed);
const sorted = [...bearingS].sort((a, b) => a - b);
const mids = sorted.slice(1).map((s, i) => (s + sorted[i]) / 2);
const used = new Set<number>();
const out: number[] = [];
for (let j = 1; j <= n; j++) {
const ideal = (lengthM * j) / (n + 1);
let best = -1;
mids.forEach((m, i) => {
if (used.has(i)) return;
if (best < 0 || Math.abs(m - ideal) < Math.abs(mids[best] - ideal) - 1e-9) best = i;
});
if (best >= 0) { used.add(best); out.push(mids[best]); } else out.push(ideal);
}
return out.sort((a, b) => a - b);
}
+2 -1
View File
@@ -2,6 +2,7 @@
* 교대 상세 (2026-11-03, 교각 상세와 같은 개념): the values the 삽도 shows, computed from the model. Read only.
* Each row is [label, value]. Lengths in mm, levels in m, as in the 삽도.
*/
import { jointPositions } from './joints';
import type { AbutRecord } from './parse';
import { buildAbut, type AbutModel } from './geometry';
import { toAbutInput } from './parse';
@@ -38,7 +39,7 @@ export function abutSummaryRows(rec: AbutRecord): [string, string][] {
['저판 폭 (앞굽 끝 / 뒷굽 끝)', `${Math.round(shape.footingFrontM * 1000)} / ${Math.round(shape.footingBackM * 1000)} mm`],
['말뚝 (열 × 본수)', `${members.piles.length} × ${members.piles[0]?.s.length ?? 0} = ${members.piles.length * (members.piles[0]?.s.length ?? 0)} 본`],
['말뚝 선단 EL', members.pileBottomEl === undefined ? '—' : m3(members.pileBottomEl)],
['수축줄눈 (ceil(L/10) − 1)', `${Math.max(0, Math.ceil(L / 10) - 1)} 개`],
['수축줄눈 (기본 ceil(L/10) − 1, 받침 사이 중간)', (() => { const js = jointPositions(L, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount); return js.length ? `${js.length} 개 · IP3에서 ${js.map((s) => (L - s).toFixed(3)).reverse().join(' / ')} m` : '0 개'; })()],
];
if (wings.length > 0) {
const w = wings[0];
+32 -3
View File
@@ -36,6 +36,7 @@ import {
} from './core/abut/edit';
import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing';
import { lenText, skewAngle } from './core/abut/rebar/skew-layout';
import { defaultJointCount, jointPositions } from './core/abut/joints';
import { ABUT_BLOCK_PAD_MM, ABUT_BLOCK_REBAR_DEFAULT, abutBlockRebar, type AbutBlockRebar, type AbutBlockSettings } from './core/abut/rebar/block';
import { ABUT_BLOCK_COLORS, abutBlockPlanSvg as abutBlockRebarPlanSvg, abutBlockSectionSvg as abutBlockRebarSectionSvg } from './view/abut-block-rebar-svg';
import { ABUT_SLAB_REBAR_DEFAULT, ABUT_SLAB_SIZES, abutSlabGeom, abutSlabRebar, type AbutSlabGeom, type AbutSlabRebar, type AbutSlabRebarSettings } from './core/abut/rebar/slab';
@@ -156,7 +157,7 @@ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
{ label: '말뚝', pier: ['piles'], abut: ['piles'] },
{ label: '버림', pier: ['blinding'], abut: ['blinding'] },
{ label: '지반면', pier: ['ground'], abut: ['ground'] },
{ label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet'] },
{ label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet', 'joints'] },
// 헌치 sits at the wing ends (wings.ts builds it per wing), so it follows the 날개벽 toggle (2026-11-03).
{ label: '날개벽', pier: [], abut: ['wings', 'haunches'] },
{ label: '접속슬래브', pier: [], abut: ['slab'] },
@@ -665,7 +666,8 @@ function setupScene(): void {
pairs: state.bridgePairs, slopeI: rec.slopeI,
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])),
});
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles,
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount));
group.visible = abutShown(state.stage, state.abutActive, rec.name);
// The 부재 표시 toggles hold for a new abutment group too.
for (const [name, on] of Object.entries(state.abutVisible)) {
@@ -1831,7 +1833,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
footingFrontM: s.footingFrontM,
footingBackM: s.footingBackM,
bracketHeightM: s.bracketHeightM,
shrinkJointCount: v.shrinkJointCount,
shrinkJointsS: jointPositions(built.model.length, built.model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount),
bracketWidthMm: v.bracketWidthMm,
parapetThicknessMm: v.parapetThicknessMm,
seatWidthMm: v.wallFrontMm,
@@ -1891,6 +1893,33 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
}
}
for (const f of SHAPE_EDIT_FIELDS) {
// 수축줄눈 개수 (spec_abut §3): 입력이 없으면 ceil(L/10) − 1 (교대 길이에 따름), 위치는 받침 사이 중간.
if (f.key === 'shrinkJointCount') {
// ① has no built model (② builds it above): build it here for the length and the bearings.
let jm: AbutModel | null = built?.model ?? null;
if (!jm) { try { jm = buildAbut(toAbutInput(rec)); } catch { jm = null; } }
const auto = jm ? defaultJointCount(jm.length) : ABUT_SHAPE_DEFAULT.shrinkJointCount;
const typed = rec.shape?.shrinkJointCount;
const id = idOf(`shape-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, typed === undefined ? '' : String(typed));
input.placeholder = `자동 ${auto}`;
if (typed !== undefined) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
if (input.value.trim() === '') {
const { shrinkJointCount: _drop, ...rest } = rec.shape ?? {};
apply({ ...rec, shape: Object.keys(rest).length ? rest : undefined });
} else apply(applyShapeEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, typed === undefined ? '' : String(typed));
}
});
const where = jm ? jointPositions(jm.length, jm.bearings.map((b) => b.s), typed).map((s) => (jm!.length - s).toFixed(3)).reverse().join(' / ') : '—';
shape.append(lab, input, el('span', `기본 자동 ceil(L/10) − 1 = ${auto} · 위치 받침 사이 중간 (IP3에서 ${where} m) · QA-7`, 'muted small'));
continue;
}
const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
const id = idOf(`shape-${f.key}`);
const lab = el('label', f.label);
+33 -1
View File
@@ -6,6 +6,7 @@
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
*/
import type { BearingSole } from '../core/sole-plate';
import { jointPositions } from '../core/abut/joints';
import * as THREE from 'three';
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
import { buildMembers, type AbutMembers } from '../core/abut/members';
@@ -14,7 +15,7 @@ import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } fr
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab'] as const;
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab', 'joints'] as const;
/** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */
const PILE_HEAD_EMBED_M = 0.1;
@@ -245,6 +246,8 @@ export function buildAbutGroup(
slabInput?: Partial<AbutSlab>,
/** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */
soles?: BearingSole[],
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
jointS?: number[],
): THREE.Group {
const root = new THREE.Group();
root.name = model.name;
@@ -316,6 +319,35 @@ export function buildAbutGroup(
add('parapet', skewPrism(parapet, -members.shape.parapetBackM, members.shape.parapetBackM), 0x8898aa);
}
// 수축줄눈 (spec_abut §4.9, 2026-11-06): lines only, on the 벽체 · 흉벽 faces (the 저판 is continuous). At each joint s:
// 벽체 전면 (t = 교좌면 끝) 저판 상면 → 교좌면, 흉벽 전면 (t = 0) 교좌면 → 흉벽 상단, 배면 (t = 벽체 배면) 저판 상면 → 브라켓 상면,
// 흉벽 배면 브라켓 상면 → 흉벽 상단. A joint between two steps takes the lower 교좌면. Lifted 3 mm off the faces.
{
const joints = jointS ?? jointPositions(L, model.bearings.map((b) => b.s));
const seatAt = (s: number) => Math.min(...members.wallSegments.filter((g) => s >= g.sFrom - 1e-6 && s <= g.sTo + 1e-6).map((g) => g.seatEl));
const pts: number[] = [];
const line = (s: number, t: number, y0: number, y1: number) => { if (y1 > y0) pts.push(s + skewK * t, y0, t, s + skewK * t, y1, t); };
const lift = 0.003, sh = members.shape;
for (const s of joints) {
const seat = seatAt(s);
if (!Number.isFinite(seat)) continue;
line(s, sh.wallFrontM + lift, footingTop, seat);
line(s, 0 + lift, seat, parapetTopAt(s));
line(s, sh.wallBackM - lift, footingTop, bracketAt(s));
line(s, sh.parapetBackM - lift, bracketAt(s), parapetTopAt(s));
}
if (pts.length) {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x1b2733 }));
lines.name = 'joints';
const group = new THREE.Group();
group.name = 'joints';
group.add(lines);
root.add(group);
}
}
if (footingBottom !== undefined) {
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
// Skew (2026-11-03): the 저판 ends follow the wing outer lines (s = sEnd + (ws/wt)·t), so the plan is a parallelogram.
+6 -5
View File
@@ -22,7 +22,8 @@ export interface AbutWallDrawing {
footingFrontM: number;
footingBackM: number;
bracketHeightM: number;
shrinkJointCount: number;
/** 수축줄눈 위치 s (m from IP1), from core/abut/joints.ts (받침 사이 중간). */
shrinkJointsS: number[];
/** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */
bracketWidthMm: number;
parapetThicknessMm: number;
@@ -113,11 +114,11 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string {
parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`));
parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no)));
}
for (let j = 1; j <= d.shrinkJointCount; j++) {
const s = (d.lengthM * j) / (d.shrinkJointCount + 1);
d.shrinkJointsS.forEach((s, i) => {
const j = i + 1;
parts.push(`<line x1="${X(s).toFixed(1)}" y1="${Y(parapetTopAt(d, s)).toFixed(1)}" x2="${X(s).toFixed(1)}" y2="${Y(d.footingTopEl).toFixed(1)}" stroke="${INK}" stroke-dasharray="5 3"/>`);
parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start'));
}
});
// 벽체 높이 H_w (사용자 도면 2026-11-04): 받침 ① 연직 기준선, 받침 ① 교좌면 ~ 흉벽 상단.
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
if (b1) {
@@ -139,7 +140,7 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string {
parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED));
parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="정면·배면도: 흉벽 상단 ${t3(d.parapetTopEndEl)} (IP3) → ${t3(d.parapetTopStartEl)} (IP1), 교좌면 계단 ${d.segments.length}구간, 수축줄눈 ${d.shrinkJointCount}">`,
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="정면·배면도: 흉벽 상단 ${t3(d.parapetTopEndEl)} (IP3) → ${t3(d.parapetTopStartEl)} (IP1), 교좌면 계단 ${d.segments.length}구간, 수축줄눈 ${d.shrinkJointsS.length}">`,
'<defs><marker id="hw-a" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#c0392b"/></marker></defs>',
...parts,
'</svg>',
+43
View File
@@ -0,0 +1,43 @@
import * as THREE from 'three';
import { describe, expect, it } from 'vitest';
import { defaultJointCount, jointCount, jointPositions } from '../src/core/abut/joints';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildAbutGroup } from '../src/view/abut-scene';
import { loadInputWorkbook } from './helpers';
// 교대 수축줄눈 (spec_abut §3 · §4.9, QA-7): count ceil(L/10) − 1, position = the middle between the nearest bearings.
const rec = parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1')!;
const model = buildAbut(toAbutInput(rec));
const bearingS = model.bearings.map((b) => b.s);
describe('교대 수축줄눈', () => {
it('count: ceil(L/10) − 1 by default (12.197 → 1, 20 → 1, 20.5 → 2, 9 → 0), a typed count wins', () => {
expect([12.197, 20, 20.5, 9].map(defaultJointCount)).toEqual([1, 1, 2, 0]);
expect(jointCount(12.197, 3)).toBe(3);
});
it('A1: the joint is not on bearing ③ (L/2) but in the middle between ② and ③, which is the 교좌면 step boundary', () => {
const [s] = jointPositions(model.length, bearingS);
const sorted = [...bearingS].sort((a, b) => a - b);
expect(s).toBeCloseTo((sorted[2] + sorted[3]) / 2, 9);
expect(Math.min(...bearingS.map((b) => Math.abs(b - s)))).toBeGreaterThan(0.6 + 0.6);
expect(model.stepBounds.some((x) => Math.abs(x - s) < 1e-3)).toBe(true);
});
it('several joints take distinct midpoints nearest to the even split; without enough bearings, the even split', () => {
const js = jointPositions(model.length, bearingS, 2);
expect(new Set(js.map((x) => x.toFixed(6))).size).toBe(2);
expect(jointPositions(30, [5], 2)).toEqual([10, 20]);
});
it('3D: one line on each of the four 벽체 · 흉벽 faces per joint, none in the 저판', () => {
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const group = buildAbutGroup(model, members);
const lines = group.getObjectByName('joints')!.children[0] as THREE.LineSegments;
const pos = lines.geometry.getAttribute('position') as THREE.BufferAttribute;
expect(pos.count).toBe(4 * 2);
for (let i = 0; i < pos.count; i++) expect(pos.getY(i)).toBeGreaterThanOrEqual(members.footingTopEl - 1e-5);
});
});
+2 -1
View File
@@ -20,7 +20,8 @@ describe('교대 상세 (삽도 항목, spec_abut §10.1)', () => {
it('the piles, joints, 접속슬래브 width and 받침블럭 height match the spec: 18 piles, tip 19.295, 1 joint, 11253 mm, 125 mm', () => {
expect(rows.get('말뚝 (열 × 본수)')).toBe('3 × 6 = 18 본');
expect(rows.get('말뚝 선단 EL')).toBe('19.295 m');
expect(rows.get('수축줄눈 (ceil(L/10) − 1)')).toBe('1 개');
// 1 joint (ceil(12.197/10) − 1), moved from L/2 (on bearing ③) to the middle between ② and ③ (2026-11-06).
expect(rows.get('수축줄눈 (기본 ceil(L/10) − 1, 받침 사이 중간)')).toMatch(/^1 개 · IP3에서 4\.90[45] m$/);
// 11.2535 m exactly (450 / sin Δ + 20 per wing); rounding to mm gives 11253 or 11254.
expect(Math.abs(parseInt(String(rows.get('접속슬래브 폭')), 10) - 11253)).toBeLessThanOrEqual(1);
expect(rows.get('받침블럭 높이 (패드 포함)')).toBe('125 mm');
+3 -2
View File
@@ -1,3 +1,4 @@
import { jointPositions } from '../src/core/abut/joints';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
@@ -26,7 +27,7 @@ const d: AbutWallDrawing = {
footingFrontM: m.shape.footingFrontM,
footingBackM: m.shape.footingBackM,
bracketHeightM: m.shape.bracketHeightM,
shrinkJointCount: 1,
shrinkJointsS: jointPositions(model.length, model.bearings.map((b) => b.s)),
bracketWidthMm: 300,
parapetThicknessMm: 500,
seatWidthMm: 1600,
@@ -50,7 +51,7 @@ describe('교대 벽체·흉벽 삽도 (시안 ② 벽체·흉벽 카드)', () =
});
it('the 수축줄눈 count follows the input', () => {
const svg = abutWallElevationSvg({ ...d, shrinkJointCount: 2 });
const svg = abutWallElevationSvg({ ...d, shrinkJointsS: jointPositions(model.length, model.bearings.map((b) => b.s), 2) });
expect(svg).toContain('수축줄눈 2');
expect(svg.match(/stroke-dasharray="5 3"/g)).toHaveLength(2);
});