feat(abut): ② 날개벽 카드를 시안대로 작성 (좌우 같게 · 측별 치수 · 삽도 3개)

- 선택 줄 (타일): 좌우 같게(기본) / 따로 정하기, 편집할 측 (좌 · ① 쪽 / 우 · ⑤ 쪽), 형식 일반형(기본) / 단축형 / 없음
- 측별 치수 입력 (WingDims, 교대 기록 wingDims): 길이 6500, 두께 450, 끝 높이 1500(일반형), 헌치 300. 단축형 길이 기본 = 뒷굽 끝(1.807 m). 좌우 같게이면 양쪽에 함께 적용, 켤 때 좌측 값을 우측에 복사
- 연동: 두께 → 날개벽 3D · 접속슬래브 폭(geometry slabWidthMm) · 슬래브 평면, 헌치 → 연직 헌치 · 저판 상면 헌치 (0이면 없음)
- 자동 값: 경사 (A1 = −i), 등단면 · 변화단면 경계(뒷굽 끝), 끝 EL 상단 / 하단 (가정), 단축형 하단 = 확대기초 상면 (뒷굽 밖은 AI02 안내)
- 삽도 3개 (src/view/abut-wing-svg.ts): 평면(좌우 날개벽 · 접속슬래브, abutPlanOutline 공유), 측면(수평 단 · 뒷굽 끝), 날개벽–흉벽 모서리 모식도(헌치 · 브라켓 · 슬래브 모따기 · 간격)
- "생성되는 날개벽" 줄과 시안 메모. 프로젝트 파일 검사에 wingDims · wingsLinked 추가
- 테스트 7건 추가 · 갱신. 전체 669건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-04 09:20:29 +09:00
1 parent a025a61209
commit 80e5302192
12 files changed
+610 -53

No files matched your search

+35
View File
@@ -4,6 +4,7 @@
*/
import type { AbutRecord } from './parse';
import type { WingForm } from './geometry';
import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings';
import { shapeIssues, type AbutShape } from './shape';
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
@@ -32,11 +33,45 @@ export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number):
return next;
}
/** 좌우 같게 (기본): the form and dims apply to both sides. Off: each side keeps its own. */
export function wingsLinked(rec: AbutRecord): boolean {
return rec.wingsLinked !== false;
}
export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord {
const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
if (wingsLinked(rec)) return { ...rec, wings: { left: form, right: form } };
return { ...rec, wings: { ...current, [side]: form } };
}
/** Turns 좌우 같게 on or off. Turning it on copies the left side (form and dims) to the right. */
export function setWingsLinked(rec: AbutRecord, linked: boolean): AbutRecord {
if (!linked) return { ...rec, wingsLinked: false };
const left = rec.wings?.left ?? 'general';
const dims = rec.wingDims?.left;
return { ...rec, wingsLinked: true, wings: { left, right: left }, wingDims: dims ? { left: { ...dims }, right: { ...dims } } : undefined };
}
/** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */
export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boolean; short: boolean }[] = [
{ key: 'lengthMm', label: '길이 (mm)', general: true, short: true },
{ key: 'thicknessMm', label: '두께 (mm)', general: true, short: true },
{ key: 'endHeightMm', label: '끝 높이 (mm)', general: true, short: false },
{ key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true },
];
/** Changes one wing dim of a side (both sides when 좌우 같게). Length, thickness and end height must be > 0, the haunch ≥ 0. */
export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const probe = { ...WING_DIMS_DEFAULT, [key]: value };
const issues = wingDimsIssues(probe);
if (issues.length > 0) throw new Error(issues[0]);
const sides: ('left' | 'right')[] = wingsLinked(rec) ? ['left', 'right'] : [side];
const next = { left: { ...(rec.wingDims?.left ?? {}) }, right: { ...(rec.wingDims?.right ?? {}) } };
for (const s of sides) next[s] = { ...next[s], [key]: value };
return { ...rec, wingDims: next };
}
/** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */
export function applyBearingEdit(rec: AbutRecord, no: number, key: 'z' | 'seatEl', value: number | undefined): AbutRecord {
const target = rec.bearings.find((b) => b.no === no);
+6 -3
View File
@@ -52,6 +52,8 @@ export interface AbutInput {
girderAzimuthDeg?: number;
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
wingThicknessMm?: { left?: number; right?: number };
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
@@ -188,10 +190,11 @@ function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide:
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
const wingAxisWidth = WING_THICKNESS_MM / sinDelta;
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
const present = (f: WingForm) => (f === 'none' ? 0 : wingAxisWidth + SLAB_CLEARANCE_MM);
const slabWidthMm = Math.round(length * 1000 - present(wings.left) - present(wings.right));
// 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|.
const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM };
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + SLAB_CLEARANCE_MM);
const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right'));
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
const slopeI = input.slopeI ?? 0;
return {
+6
View File
@@ -12,6 +12,7 @@ import type { Issue } from '../types';
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
import type { AbutBearingRule } from './bearing-table';
import type { WingDims } from './wings';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
@@ -56,6 +57,10 @@ export interface AbutRecord {
shape?: Partial<AbutShape>;
/** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */
bearingRule?: Partial<AbutBearingRule>;
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
/** 좌우 같게 (기본 true): 형식과 치수를 양쪽에 함께 적용한다. */
wingsLinked?: boolean;
}
export interface AbutParseResult {
@@ -207,6 +212,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput {
skewDeg: rec.skewDeg,
slopeI: rec.slopeI,
wings: rec.wings,
wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm },
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
+1 -1
View File
@@ -14,7 +14,7 @@ const mm = (v: number) => `${Math.round(v)} mm`;
export function abutSummaryRows(rec: AbutRecord): [string, string][] {
const model: AbutModel = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const wings = buildWings(model, members, rec.wings);
const wings = buildWings(model, members, rec.wings, rec.wingDims);
const shape = members.shape;
const first = model.bearings[0];
const L = model.length;
+65 -17
View File
@@ -21,11 +21,43 @@ export const WING_THICKNESS_M = 0.45;
/** 단부 높이 (끝점 상단에서 단부 하단까지, m). */
export const WING_END_HEIGHT_M = 1.5;
/** 날개벽 치수 입력 (측별, mm, 2026-11-04 시안 ② 날개벽 카드). 단축형 길이의 기본값은 뒷굽 끝까지의 거리다. */
export interface WingDims {
lengthMm: number;
thicknessMm: number;
endHeightMm: number;
haunchMm: number;
}
export const WING_DIMS_DEFAULT: WingDims = { lengthMm: 6500, thicknessMm: 450, endHeightMm: 1500, haunchMm: 300 };
/** The dims of one side: the user's values over the defaults; 단축형 without a length input runs to the 뒷굽 끝. */
export function resolveWingDims(form: WingForm, changes: Partial<WingDims> | undefined, heelDistanceM: number): WingDims {
const base = { ...WING_DIMS_DEFAULT, ...(changes ?? {}) };
if (form === 'short' && changes?.lengthMm === undefined) base.lengthMm = Math.round(heelDistanceM * 1000);
return base;
}
/** Input problems of one side's dims, in Korean; empty when the wing can be built. */
export function wingDimsIssues(d: WingDims): string[] {
const out: string[] = [];
if (!(d.lengthMm > 0)) out.push('날개벽 길이는 0보다 커야 합니다.');
if (!(d.thicknessMm > 0)) out.push('날개벽 두께는 0보다 커야 합니다.');
if (!(d.endHeightMm > 0)) out.push('날개벽 끝 높이는 0보다 커야 합니다.');
if (!(d.haunchMm >= 0)) out.push('헌치는 0 이상이어야 합니다.');
return out;
}
export interface WingSolid {
side: 'left' | 'right';
form: Exclude<WingForm, 'none'>;
/** 날개벽이 시작하는 축 끝: 0 (IP1 끝) 또는 L (IP3 끝). */
sEnd: number;
/** 이 측의 치수 (m): 길이, 두께, 끝 높이, 헌치 다리. */
lengthM: number;
thicknessM: number;
endHeightM: number;
haunchM: number;
/** 평면 네 꼭짓점 (E, N), 순서: 시작, 끝, 끝 안쪽, 시작 안쪽. */
corners: [AbutPoint, AbutPoint, AbutPoint, AbutPoint];
topStartEl: number;
@@ -66,7 +98,12 @@ export interface Haunch {
export const HAUNCH_LEG_M = 0.3;
export function buildWings(model: AbutModel, members: AbutMembers, forms?: { left: WingForm; right: WingForm }): WingSolid[] {
export function buildWings(
model: AbutModel,
members: AbutMembers,
forms?: { left: WingForm; right: WingForm },
dims?: { left?: Partial<WingDims>; right?: Partial<WingDims> },
): WingSolid[] {
if (!model.wing) return [];
const L = model.length;
const wings = forms ?? model.wing.wings;
@@ -89,26 +126,30 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef
const build = (side: 'left' | 'right', sEnd: number, topStart: number, form: Exclude<WingForm, 'none'>): WingSolid => {
const a = sEnd === 0 ? 1 : -1;
const dm = resolveWingDims(form, dims?.[side], heelDistanceM);
const length = dm.lengthMm / 1000;
const thick = dm.thicknessMm / 1000;
const leg = dm.haunchMm / 1000;
const p0 = at(sEnd);
const p1 = { e: p0.e + w.e * WING_LENGTH_M, n: p0.n + w.n * WING_LENGTH_M };
const inward = { e: model.u.e * a * WING_THICKNESS_M, n: model.u.n * a * WING_THICKNESS_M };
const topEnd = topStart + slope * WING_LENGTH_M;
const bottomEnd = form === 'general' ? topEnd - WING_END_HEIGHT_M : footingTop;
const p1 = { e: p0.e + w.e * length, n: p0.n + w.n * length };
const inward = { e: model.u.e * a * thick, n: model.u.n * a * thick };
const topEnd = topStart + slope * length;
const bottomEnd = form === 'general' ? topEnd - dm.endHeightMm / 1000 : footingTop;
const topAt = (d: number) => topStart + slope * d;
const topLine = [
{ dM: 0, el: topStart },
{ dM: WING_LENGTH_M, el: topEnd },
{ dM: length, el: topEnd },
];
const bottomLine =
form === 'general' && heelDistanceM < WING_LENGTH_M
form === 'general' && heelDistanceM < length
? [
{ dM: 0, el: footingTop },
{ dM: heelDistanceM, el: footingTop },
{ dM: WING_LENGTH_M, el: bottomEnd },
{ dM: length, el: bottomEnd },
]
: [
{ dM: 0, el: footingTop },
{ dM: WING_LENGTH_M, el: form === 'general' ? bottomEnd : footingTop },
{ dM: length, el: form === 'general' ? bottomEnd : footingTop },
];
// 헌치: 위 헌치는 흉벽 배면(−0.5), 아래 헌치는 벽체 배면(−0.8)의 모서리에 둔다.
@@ -122,8 +163,8 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef
const d = Math.abs(t) / k;
// The corner lies on the 날개벽 내측면 (the face between the two wings), WING_THICKNESS_M in from the axis end.
const inwardUnit = { e: model.u.e * a, n: model.u.n * a };
const c = shift(pointAt(d), inwardUnit, WING_THICKNESS_M);
const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, HAUNCH_LEG_M), shift(c, inwardUnit, HAUNCH_LEG_M)];
const c = shift(pointAt(d), inwardUnit, thick);
const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, leg), shift(c, inwardUnit, leg)];
return {
kind,
corners,
@@ -132,14 +173,17 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef
};
};
// 연직 헌치 (2026-11-03, 내측면 모서리): upper at the 흉벽 배면, lower at the 벽체 배면.
const haunches = [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)];
const haunches = leg > 0 ? [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)] : [];
// 저판 상면 헌치 (수평): from the 벽체 배면 to the 뒷굽, on the 저판 상면 next to the 날개벽 내측면.
const footingHaunch = {
dMFrom: Math.abs(members.shape.wallBackM) / k,
dMTo: heelDistanceM,
el: footingTop,
};
const footingHaunch =
leg > 0
? {
dMFrom: Math.abs(members.shape.wallBackM) / k,
dMTo: Math.min(heelDistanceM, length),
el: footingTop,
}
: undefined;
// 수평 단 (QA-12): 등단면 = 끝 상단 − 1.5 m 위, 변화단면1 = 그 아래 ~ 교좌면 EL, 변화단면2 = 교좌면 EL ~ 저판 상면.
// 교좌면 EL은 날개벽 끝에 가장 가까운 받침의 교좌면이다.
@@ -157,6 +201,10 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef
side,
form,
sEnd,
lengthM: length,
thicknessM: thick,
endHeightM: dm.endHeightMm / 1000,
haunchM: leg,
corners: [p0, p1, { e: p1.e + inward.e, n: p1.n + inward.n }, { e: p0.e + inward.e, n: p0.n + inward.n }],
topStartEl: topStart,
topEndEl: topEnd,
+8
View File
@@ -135,6 +135,14 @@ export function checkStoredAbutments(list: unknown): void {
if (key === 'kind' ? typeof v !== 'string' : !finite(v)) throw new Error(`${who}: 받침 규칙 ${key}이(가) 맞지 않습니다.`);
}
}
if (a.wingDims !== undefined) {
for (const sideDims of [a.wingDims.left, a.wingDims.right]) {
for (const [key, v] of Object.entries(sideDims ?? {})) {
if (!finite(v)) throw new Error(`${who}: 날개벽 치수 ${key}이(가) 숫자가 아닙니다.`);
}
}
}
if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`);
if (a.wings !== undefined) {
const forms = ['general', 'short', 'none'];
if (!forms.includes(a.wings.left) || !forms.includes(a.wings.right)) throw new Error(`${who}: 날개벽 형식이 맞지 않습니다.`);
+187 -5
View File
@@ -8,7 +8,22 @@ import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse'
import { buildAbut } from './core/abut/geometry';
import { structureOrder } from './core/abut/order';
import { ABUT_DETAIL_LABELS, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary';
import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, applyShapeEdits, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
import {
applyAbutEdit,
applyBearingEdit,
applyBearingRule,
applyShapeEdit,
applyShapeEdits,
applyWingDim,
ABUT_EDIT_FIELDS,
BEARING_RULE_FIELDS,
SHAPE_EDIT_FIELDS,
WING_DIM_FIELDS,
restoreAbutDefault,
setWingForm,
setWingsLinked,
wingsLinked,
} from './core/abut/edit';
import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape';
import { abutFootingPlanSvg } from './view/abut-footing-svg';
import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg';
@@ -27,8 +42,9 @@ import {
import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list';
import type { AbutModel, WingForm } from './core/abut/geometry';
import { buildMembers, type AbutMembers } from './core/abut/members';
import { buildWings } from './core/abut/wings';
import { abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { buildWings, resolveWingDims, type WingSolid } from './core/abut/wings';
import { abutPlanOutline, abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from './view/abut-wing-svg';
import { fitCamera } from './view/fit';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
@@ -151,6 +167,8 @@ const state = {
abutMember: 'bearing' as AbutMemberId,
/** The bearing the ② 교량받침 삽도 draw (상세 보기). */
abutBearingNo: 1,
/** The side the ② 날개벽 card edits (편집할 측). */
abutWingSide: 'left' as 'left' | 'right',
/** The editable part of the 교대 panel is open (2026-11-03). */
abutEditOpen: false,
/** 교대 기본값 (가져온 그대로), for 기본값 복원. */
@@ -590,7 +608,7 @@ function setupScene(): void {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
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)) {
@@ -948,6 +966,168 @@ function fillAbutBearingCard(
box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks'));
}
/** skew (deg) as 95°02′52″. */
function dms(deg: number): string {
const total = Math.round(Math.abs(deg) * 3600);
const d = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(2, '0')}″`;
}
/** ② 날개벽 카드 (시안, 2026-11-04): 좌우 · 편집할 측 · 형식 → 삽도 3개 → 측별 치수 → 자동 값 → 생성되는 날개벽 → 메모. */
function fillAbutWingCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): void {
const NAME: Record<WingForm, string> = { general: '일반형', short: '단축형', none: '없음' };
const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
const linked = wingsLinked(rec);
const side = state.abutWingSide;
const lastNo = rec.bearings.length;
const circled = (n: number) => ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'][n - 1] ?? String(n);
const sideName = (s: 'left' | 'right') => (s === 'left' ? '좌 · ① 쪽' : `우 · ${circled(lastNo)} 쪽`);
box.append(
optionTiles('좌우', [
{ label: '좌우 같게', on: linked, isDefault: true, pick: () => apply(setWingsLinked(rec, true)) },
{ label: '따로 정하기', on: !linked, pick: () => apply(setWingsLinked(rec, false)) },
]),
optionTiles(
'편집할 측',
(['left', 'right'] as const).map((s) => ({
label: `${sideName(s)} · ${NAME[forms[s]]}`,
on: side === s,
pick: () => {
state.abutWingSide = s;
renderAll();
},
})),
linked ? '좌우 같게: 형식과 치수가 양쪽에 함께 적용됩니다' : undefined,
),
optionTiles(
`${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) 형식`,
(['general', 'short', 'none'] as const).map((f) => ({
label: NAME[f],
on: forms[side] === f,
isDefault: f === 'general',
pick: () => apply(setWingForm(rec, side, f)),
})),
),
);
let model: AbutModel;
let members: AbutMembers;
let wings: WingSolid[];
try {
model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
wings = buildWings(model, members, forms, rec.wingDims);
} catch {
box.append(el('p', '교대 모델을 만들 수 없어 날개벽을 그리지 않습니다.', 'muted small'));
return;
}
const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
const wing = wings.find((w) => w.side === side);
const leftWing = wings.find((w) => w.side === 'left');
// 뒷굽 끝까지의 날개벽 거리 (단축형 길이의 기본값); with no wing drawn, the 뒷굽 width is a fair stand-in.
const heelM = wing?.heelDistanceM ?? leftWing?.heelDistanceM ?? Math.abs(members.shape.footingBackM);
// 삽도 3개.
const figs = el('div', undefined, 'abut-wall-figs abut-wing-figs');
const addFig = (svg: string, caption: string) => {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
};
addFig(
abutWingPlanSvg({
lengthM: model.length,
outline: abutPlanOutline(model, members, wings),
leftLabel: `좌 · ${NAME[forms.left]}`,
rightLabel: `우 · ${NAME[forms.right]}`,
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
slabWidthM: (model.wing?.slabWidthMm ?? 0) / 1000,
}),
'평면 · 좌우 날개벽과 접속슬래브 · 축척 같음',
);
if (wing) {
addFig(
abutWingSideSvg({
name: `${side === 'left' ? '좌' : '우'} 날개벽`,
form: wing.form,
lengthM: wing.lengthM,
endHeightM: wing.endHeightM,
heelDistanceM: wing.heelDistanceM,
topLine: wing.topLine,
bottomLine: wing.bottomLine,
bands: wing.bands,
seatEl: rec.seatEl,
footingTopEl: members.footingTopEl,
}),
`${side === 'left' ? '좌' : '우'} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`,
);
}
addFig(
abutWingCornerSvg({
parapetMm: shapeAll.parapetThicknessMm,
bracketMm: shapeAll.bracketWidthMm,
wingThicknessMm: Math.round((wing?.thicknessM ?? 0.45) * 1000),
haunchMm: Math.round((wing?.haunchM ?? 0.3) * 1000),
slabGapWingMm: 20,
slabGapParapetMm: 5,
chamferMm: 300,
}),
'날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기',
);
box.append(figs);
// 측별 치수 (선택한 측의 형식에 맞는 칸).
const form = forms[side];
box.append(el('p', `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) · ${NAME[form]}`, 'lbl'));
if (form === 'none' || !wing) {
box.append(el('p', '입력 없음 · 접속슬래브 측면은 교좌면 끝선에 맞춥니다.', 'muted small'));
} else {
const dims = resolveWingDims(form, rec.wingDims?.[side], heelM);
const defaults = resolveWingDims(form, undefined, heelM);
for (const f of WING_DIM_FIELDS.filter((x) => (form === 'general' ? x.general : x.short))) {
const id = idOf(`wing-${side}-${f.key}`);
const lab = el('label', form === 'short' && f.key === 'lengthMm' ? '길이 (mm) · 기본 = 뒷굽 끝' : f.label);
lab.htmlFor = id;
const input = numberInput(id, String(dims[f.key]));
if (dims[f.key] !== defaults[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyWingDim(rec, side, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(dims[f.key]));
}
});
box.append(lab, input, el('span', `기본 ${defaults[f.key]}${linked ? ' · 양쪽' : ''}`, 'muted small'));
}
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
const slope = model.wing?.slopePct ?? 0;
auto(`경사 (자동, ${rec.type} = ${rec.type === 'A1' ? '−i' : '+i'})`, `${slope >= 0 ? '+' : ''}${slope.toFixed(2)} %`, '종단경사에서');
if (form === 'general') {
auto('등단면 · 변화단면 경계 (자동)', `${wing.heelDistanceM.toFixed(3)} m`, '뒷굽 끝');
auto('끝 EL 상단 / 하단', `${wing.topEndEl.toFixed(3)} / ${wing.bottomEndEl.toFixed(3)}`, '가정');
} else {
auto('하단 (자동)', `확대기초 상면 ${members.footingTopEl.toFixed(3)}`, wing.lengthM > wing.heelDistanceM + 1e-6 ? '뒷굽 밖은 하단이 떠 있음 (정보 AI02)' : '');
auto('끝 EL 상단', wing.topEndEl.toFixed(3), '가정');
}
}
const made = (s: 'left' | 'right') => (forms[s] === 'none' ? `${s === 'left' ? '좌' : '우'} 없음` : `${s === 'left' ? '좌' : '우'} ${NAME[forms[s]]}`);
box.append(el('p', `생성되는 날개벽: ${made('left')} · ${made('right')} · 각도는 좌우 모두 skew ${dms(rec.skewDeg)}`, 'muted small'));
box.append(
el(
'p',
'시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). "좌우 같게"를 끄면 우측을 따로 정할 수 있습니다. 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.',
'muted small abut-memo',
),
);
}
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
function fillAbutFootingExtras(
box: HTMLDivElement,
@@ -1121,7 +1301,9 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
});
basic.append(lab, input);
}
for (const side of ['left', 'right'] as const) {
// ② draws the 날개벽 card (시안); ① keeps the two form selects.
if (planCard) fillAbutWingCard(wingBox, rec, apply, fail, idOf, numberInput);
for (const side of planCard ? [] : (['left', 'right'] as const)) {
const id = idOf(`wing-${side}`);
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
lab.htmlFor = id;
+6
View File
@@ -934,3 +934,9 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.abut-opt:disabled, .abut-optt:disabled { cursor: not-allowed; border-style: dashed; color: #5f6a76; background: #f6f8fa; }
.abut-def { position: absolute; top: -9px; right: 4px; font-size: 11px; font-weight: 600; line-height: 1; padding: 3px 6px; border-radius: 8px; background: #4a5868; color: #ffffff; }
.abut-tbdn { display: block; font-size: 12px; color: #55606e; }
/* ② 날개벽 카드 (2026-11-04): tiles and the three 삽도 across the card, then the per-side inputs in the 3-column grid. */
#abut-panel-plan-wing { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan-wing > p, #abut-panel-plan-wing > .abut-wall-figs { grid-column: 1 / -1; }
#abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr); }
@media (max-width: 1100px) { #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1fr); } }
+42 -23
View File
@@ -8,7 +8,7 @@
import * as THREE from 'three';
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
import { buildMembers, type AbutMembers } from '../core/abut/members';
import { buildWings, HAUNCH_LEG_M, WING_THICKNESS_M, type WingSolid } from '../core/abut/wings';
import { buildWings, type WingSolid } from '../core/abut/wings';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
@@ -120,6 +120,39 @@ function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
}
/** Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8). */
export interface AbutPlanOutline {
wall: [number, number][];
wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[];
slab: [number, number][];
}
export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[]): AbutPlanOutline {
const L = model.length;
const k = abutSkewK(model);
const tb = members.shape.wallBackM;
const tf = members.shape.wallFrontM;
const near = members.shape.parapetBackM - 0.005;
const far = near - SLAB_LENGTH_M;
const innerS = (sEnd: number, t: number): number => {
const w = wings.find((x) => x.sEnd === sEnd);
if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선
const a = w.sEnd === 0 ? 1 : -1;
const p0 = toAxis(model, w.corners[0]);
const p1 = toAxis(model, w.corners[1]);
return sEnd + a * (w.thicknessM + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
};
const axis = (c: AbutPoint): [number, number] => {
const p = toAxis(model, c);
return [p.s, p.t];
};
return {
wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]],
wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })),
slab: [[innerS(0, near), near], [innerS(L, near), near], [innerS(L, far), far], [innerS(0, far), far]],
};
}
/** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */
export function abutSkewK(model: AbutModel): number {
const d = wingDirectionAxis(model);
@@ -257,27 +290,13 @@ export function buildAbutGroup(
}
// 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall.
// Plan: between the wing inner faces, each 20 mm inside (spec §4.8 "측면 = 날개벽 안쪽면 + 20"), so it never meets a wing.
// The wing is drawn as a prism over the wing direction with its 0.45 m thickness along s, so its inner face at t is s = sEnd + a·0.45 + (ws/wt)·t.
// Plan: between the wing inner faces, each 20 mm inside (spec §4.8), from abutPlanOutline (shared with the ② 날개벽 plan).
const slabNear = members.shape.parapetBackM - 0.005;
const slabFar = slabNear - SLAB_LENGTH_M;
const innerS = (sEnd: number, t: number): number => {
const w = wings.find((x) => x.sEnd === sEnd);
if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선
const a = w.sEnd === 0 ? 1 : -1;
const p0 = toAxis(model, w.corners[0]);
const p1 = toAxis(model, w.corners[1]);
return sEnd + a * (WING_THICKNESS_M + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
};
const slabPlan = abutPlanOutline(model, members, wings).slab;
const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM;
const slabMesh = new THREE.Mesh(
slabGeometry(
[
[innerS(0, slabNear), slabNear],
[innerS(L, slabNear), slabNear],
[innerS(L, slabFar), slabFar],
[innerS(0, slabFar), slabFar],
],
slabPlan,
// Flat on the bracket where it lies over the back strip (t ≥ 벽체 배면), falling 4 % only behind the wall.
(s, t) => bracketTopEl(s) - SLAB_FALL * Math.max(0, members.shape.wallBackM - t),
),
@@ -319,21 +338,21 @@ export function buildAbutGroup(
// The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it.
// The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest.
const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L;
const highEnd = slopeS < 0 ? Math.min(0, a * WING_THICKNESS_M) : Math.max(0, a * WING_THICKNESS_M);
const highEnd = slopeS < 0 ? Math.min(0, a * wing.thicknessM) : Math.max(0, a * wing.thicknessM);
const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM;
const above = clipByLine(profile, (x, y) => y - bracketAtDm(x));
const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y);
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
add('wings', upper, 0xc9a77c);
add('wings', lower, 0xc9a77c);
// 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면,
// extruded along the wing from the 벽체 배면 to the 뒷굽.
if (wing.footingHaunch) {
const f = wing.footingHaunch;
const leg = HAUNCH_LEG_M;
const leg = wing.haunchM;
const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]];
const origin = new THREE.Vector3(sEnd + a * WING_THICKNESS_M + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c);
}
for (const h of wing.haunches) {
+170
View File
@@ -0,0 +1,170 @@
/**
* 교대 날개벽 삽도 (2026-11-04, 시안 ② 날개벽 카드): 평면(좌우 날개벽과 접속슬래브), 측면(날개벽 축 방향), 날개벽–흉벽 모서리 평면.
* 순수 문자열 함수. 평면·측면은 생성본 값으로 그리고 가로·세로 축척이 같다. 모서리 상세는 치수를 나타내는 모식도다.
*/
import type { AbutPlanOutline } from './abut-scene';
const INK = '#18212b';
const MUTED = '#55606e';
const WING = '#d6bb8c';
const WALL = '#b7c7d3';
const SLAB = '#f3eee4';
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
export interface AbutWingPlanDrawing {
lengthM: number;
outline: AbutPlanOutline;
/** 좌 = ① 쪽 끝 (sEnd), 우 = 반대 끝. 이름에 형식을 붙여 표시한다. */
leftLabel: string;
rightLabel: string;
leftSEnd: number;
slabWidthM: number;
}
/** 평면: 벽체(skew), 좌우 날개벽, 그 사이 접속슬래브. 왼쪽이 ① 쪽(좌), 위가 전면. */
export function abutWingPlanSvg(d: AbutWingPlanDrawing): string {
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
// ① 쪽(좌)을 왼쪽에 둔다: 좌가 s = L이면 s를 뒤집는다.
const flip = d.leftSEnd !== 0;
const xs = pts.map(([s]) => (flip ? -s : s));
const ts = pts.map(([, t]) => t);
const k = 30;
const xMin = Math.min(...xs) - 0.6;
const tMax = Math.max(...ts) + 0.4;
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
const Y = (t: number) => 18 + (tMax - t) * k;
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
const H = Y(Math.min(...ts) - 0.4) + 30;
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
const parts = [
poly(map(d.outline.slab), `fill="${SLAB}" stroke="#b49a6b" stroke-dasharray="5 3"`),
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
];
const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / 4, a[1] + t / 4], [0, 0]);
parts.push(text(X(slabC[0]), Y(slabC[1]), `접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m`, 'middle'));
for (const w of d.outline.wings) {
const far = w.poly[1];
parts.push(text(X(far[0]), Y(far[1]) + 16, w.side === 'left' ? d.leftLabel : d.rightLabel, 'middle'));
}
parts.push(text(X(d.lengthM / 2), 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="평면: ${d.leftLabel}, ${d.rightLabel}, 접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingSideDrawing {
name: string;
form: 'general' | 'short';
lengthM: number;
endHeightM: number;
heelDistanceM: number;
topLine: { dM: number; el: number }[];
bottomLine: { dM: number; el: number }[];
bands: { name: string; zTopEl: number; zBottomEl: number }[];
seatEl: number;
footingTopEl: number;
}
/** 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽. 수평 단 경계와 뒷굽 끝을 표시한다. */
export function abutWingSideSvg(d: AbutWingSideDrawing): string {
const k = 40;
const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.footingTopEl];
const elMax = Math.max(...els) + 0.8;
const elMin = Math.min(...els) - 0.6;
const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) on the right
const Y = (el: number) => 14 + (elMax - el) * k;
const W = X(-1.2) + 70;
const H = Y(elMin) + 26;
const outline: [number, number][] = [
...d.topLine.map((p) => [X(p.dM), Y(p.el)] as [number, number]),
...[...d.bottomLine].reverse().map((p) => [X(p.dM), Y(p.el)] as [number, number]),
];
const parts = [
// 벽체 (단면) on the right, 저판 under it.
poly([[X(0), Y(d.footingTopEl)], [X(-1.0), Y(d.footingTopEl)], [X(-1.0), Y(d.seatEl)], [X(0), Y(d.seatEl)]], `fill="${WALL}" stroke="${MUTED}"`),
poly([[X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl - 0.5)], [X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl - 0.5)]], `fill="#c9d1da" stroke="${MUTED}"`),
poly(outline, `fill="${WING}" stroke="#9c8763"`),
];
// 수평 단 경계 (등단면 / 변화단면) as dashed lines across the wing.
for (const b of d.bands.slice(0, -1)) {
parts.push(`<line x1="${X(d.lengthM).toFixed(1)}" y1="${Y(b.zBottomEl).toFixed(1)}" x2="${X(0).toFixed(1)}" y2="${Y(b.zBottomEl).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
}
for (const b of d.bands) {
const mid = (b.zTopEl + b.zBottomEl) / 2;
parts.push(text(X(d.lengthM * 0.45), Y(mid) + 4, b.name, 'middle', MUTED));
}
parts.push(text((X(d.lengthM) + X(0)) / 2, 11, `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`, 'middle'));
if (d.form === 'general') parts.push(text(X(d.lengthM) - 4, (Y(d.topLine[d.topLine.length - 1].el) + Y(d.bottomLine[d.bottomLine.length - 1].el)) / 2 + 4, `${Math.round(d.endHeightM * 1000)}`, 'end'));
parts.push(text(X(-1.0) + 4, Y(d.seatEl) + 4, `교좌면 ${d.seatEl.toFixed(3)}`, 'start'));
parts.push(text(X(-1.2) + 4, Y(d.footingTopEl) + 14, d.footingTopEl.toFixed(3), 'start'));
if (d.heelDistanceM < d.lengthM) {
parts.push(`<line x1="${X(d.heelDistanceM).toFixed(1)}" y1="${Y(d.footingTopEl).toFixed(1)}" x2="${X(d.heelDistanceM).toFixed(1)}" y2="${(Y(d.footingTopEl) + 18).toFixed(1)}" stroke="${INK}"/>`);
}
parts.push(text(X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl) + 30, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle'));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="${d.name} 측면: 길이 ${Math.round(d.lengthM * 1000)}, 뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingCornerDrawing {
parapetMm: number;
bracketMm: number;
wingThicknessMm: number;
haunchMm: number;
/** 접속슬래브 간격: 날개벽과 20, 흉벽과 5. 모따기 300 × 300 (spec §4.8). */
slabGapWingMm: number;
slabGapParapetMm: number;
chamferMm: number;
}
/** 좌 날개벽–흉벽 모서리 평면 (모식도): 흉벽, 브라켓(슬래브 아래), 날개벽, 헌치, 접속슬래브(모따기, 간격). */
export function abutWingCornerSvg(d: AbutWingCornerDrawing): string {
const k = 0.14; // px per mm
const x0 = 70; // 날개벽 내측면
const y0 = 60; // 흉벽 배면
const X = (mm: number) => x0 + mm * k;
const Y = (mm: number) => y0 + mm * k;
const span = 2200; // drawn extent from the corner (mm)
const h = d.haunchMm;
const c = d.chamferMm;
const sx = d.slabGapWingMm;
const sy = d.slabGapParapetMm;
const parts = [
// 흉벽 (above the 배면 line) and the 교대축 at its front face.
poly([[X(-d.wingThicknessMm), Y(-d.parapetMm)], [X(span), Y(-d.parapetMm)], [X(span), Y(0)], [X(-d.wingThicknessMm), Y(0)]], `fill="${WALL}" stroke="${MUTED}"`),
`<line x1="${X(-d.wingThicknessMm - 200).toFixed(1)}" y1="${Y(-d.parapetMm).toFixed(1)}" x2="${X(span).toFixed(1)}" y2="${Y(-d.parapetMm).toFixed(1)}" stroke="#c0392b" stroke-dasharray="8 3 2 3"/>`,
// 브라켓 (under the slab, dashed).
`<rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(span * k).toFixed(1)}" height="${(d.bracketMm * k).toFixed(1)}" fill="none" stroke="${MUTED}" stroke-dasharray="4 3"/>`,
// 날개벽 (left, running back = down).
poly([[X(-d.wingThicknessMm), Y(0)], [X(0), Y(0)], [X(0), Y(span)], [X(-d.wingThicknessMm), Y(span)]], `fill="${WING}" stroke="#9c8763"`),
];
if (h > 0) parts.push(poly([[X(0), Y(0)], [X(h), Y(0)], [X(0), Y(h)]], `fill="${WING}" stroke="#9c8763"`));
// 접속슬래브 with the corner cut (모따기) and the gaps.
parts.push(
poly(
[[X(sx + c), Y(sy)], [X(span), Y(sy)], [X(span), Y(span)], [X(sx), Y(span)], [X(sx), Y(sy + c)]],
`fill="${SLAB}" stroke="#b49a6b"`,
),
);
parts.push(text(X(-d.wingThicknessMm / 2), Y(span) - 6, `날개벽 ${d.wingThicknessMm}`, 'middle'));
parts.push(text(X(span / 2), Y(-d.parapetMm / 2) + 4, `흉벽 ${d.parapetMm}`, 'middle'));
parts.push(text(X(span) - 4, Y(-d.parapetMm) - 6, '교대축 (흉벽 전면)', 'end', '#9b2c1f'));
parts.push(text(X(span) - 4, Y(d.bracketMm) - 4, `브라켓 ${d.bracketMm} (슬래브 아래)`, 'end', MUTED));
if (h > 0) parts.push(text(X(h) + 6, Y(h / 2) + 4, `헌치 ${h} × ${h}`, 'start'));
parts.push(text(X(sx + c) + 8, Y(sy + c) + 14, `슬래브 모따기 ${c} × ${c}`, 'start'));
parts.push(text(X(span / 2), Y(span) - 6, `접속슬래브 · 날개벽과 ${sx} · 흉벽과 ${sy}`, 'middle'));
const W = X(span) + 20;
const H = Y(span) + 16;
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="날개벽–흉벽 모서리: 헌치 ${h} × ${h}, 브라켓 ${d.bracketMm}, 슬래브 모따기 ${c} × ${c}, 간격 ${sx} / ${sy}">`,
...parts,
'</svg>',
].join('');
}
+16 -4
View File
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm } from '../src/core/abut/edit';
import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm, setWingsLinked, applyWingDim } from '../src/core/abut/edit';
import { parseAbutments } from '../src/core/abut/parse';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
@@ -25,11 +25,23 @@ describe('교대 입력 편집 (교대 편집 카드)', () => {
expect(() => applyAbutEdit(rec, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단 EL보다 높아야');
});
it('a wing form change sets one side and keeps the other', () => {
const next = setWingForm(rec, 'left', 'short');
expect(next.wings).toEqual({ left: 'short', right: 'general' });
it('a wing form change sets both sides while 좌우 같게 (default), one side once it is off', () => {
expect(setWingForm(rec, 'left', 'short').wings).toEqual({ left: 'short', right: 'short' });
const apart = setWingsLinked(rec, false);
expect(setWingForm(apart, 'left', 'short').wings).toEqual({ left: 'short', right: 'general' });
expect(rec.wings).toBeUndefined();
});
it('wing dims: 좌우 같게 applies to both sides, off applies to one; turning it on copies the left side', () => {
const both = applyWingDim(rec, 'left', 'lengthMm', 6000);
expect(both.wingDims).toEqual({ left: { lengthMm: 6000 }, right: { lengthMm: 6000 } });
const apart = applyWingDim(setWingsLinked(rec, false), 'right', 'thicknessMm', 500);
expect(apart.wingDims).toEqual({ left: {}, right: { thicknessMm: 500 } });
const relinked = setWingsLinked(applyWingDim(apart, 'left', 'lengthMm', 5000), true);
expect(relinked.wingDims).toEqual({ left: { lengthMm: 5000 }, right: { lengthMm: 5000 } });
expect(() => applyWingDim(rec, 'left', 'thicknessMm', 0)).toThrow('두께');
expect(() => applyWingDim(rec, 'left', 'haunchMm', -1)).toThrow('헌치');
});
});
describe('교대 기본값 복원과 받침 편집', () => {
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { abutPlanOutline } from '../src/view/abut-scene';
import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from '../src/view/abut-wing-svg';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const build = (r = rec) => {
const model = buildAbut(toAbutInput(r));
const members = buildMembers(model, { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape);
return { model, members, wings: buildWings(model, members, r.wings, r.wingDims) };
};
describe('날개벽 치수 입력 (측별) 과 연동', () => {
it('defaults: 6500 · 450 · 1500 · haunch 300 on both sides; 단축형 runs to the 뒷굽 끝 (1.807 m)', () => {
const { wings } = build();
for (const w of wings) expect([w.lengthM, w.thicknessM, w.endHeightM, w.haunchM]).toEqual([6.5, 0.45, 1.5, 0.3]);
const short = build({ ...rec, wings: { left: 'short', right: 'short' } }).wings[0];
expect(short.lengthM).toBeCloseTo(short.heelDistanceM, 3);
expect(short.heelDistanceM).toBeCloseTo(1.807, 3);
});
it('a thicker wing narrows the 접속슬래브 (slab width = L − Σ(t/|sin Δ| + 20))', () => {
const base = build().model.wing!.slabWidthMm;
const thick = build({ ...rec, wingDims: { left: { thicknessMm: 550 }, right: { thicknessMm: 550 } } });
expect(base - thick.model.wing!.slabWidthMm).toBeGreaterThan(195); // 2 × 100 / |sin Δ| ≈ 200.8
expect(thick.wings[0].thicknessM).toBeCloseTo(0.55, 9);
});
it('haunch 0 drops the 연직 헌치 and the 저판 상면 헌치 of that wing', () => {
const w = build({ ...rec, wingDims: { left: { haunchMm: 0 }, right: {} } }).wings.find((x) => x.side === 'left')!;
expect(w.haunches).toHaveLength(0);
expect(w.footingHaunch).toBeUndefined();
});
});
describe('날개벽 삽도 (시안 ② 날개벽 카드)', () => {
it('평면: both wings, the slab between them and its width', () => {
const { model, members, wings } = build();
const svg = abutWingPlanSvg({
lengthM: model.length, outline: abutPlanOutline(model, members, wings), leftLabel: '좌 · 일반형', rightLabel: '우 · 일반형',
leftSEnd: wings.find((w) => w.side === 'left')!.sEnd, slabWidthM: model.wing!.slabWidthMm / 1000,
});
for (const s of ['좌 · 일반형', '우 · 일반형', `접속슬래브 폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`]) expect(svg).toContain(s);
expect(svg.match(/<polygon /g)).toHaveLength(4); // slab, wall, two wings
});
it('측면: the bands, the length, the end height and the 뒷굽 끝', () => {
const { members, wings } = build();
const w = wings[0];
const svg = abutWingSideSvg({
name: '좌 날개벽', form: w.form, lengthM: w.lengthM, endHeightM: w.endHeightM, heelDistanceM: w.heelDistanceM,
topLine: w.topLine, bottomLine: w.bottomLine, bands: w.bands, seatEl: rec.seatEl, footingTopEl: members.footingTopEl,
});
for (const s of ['6500 (교좌면 끝에서, 날개벽 축 방향)', '1500', '등단면', '변화단면1', '변화단면2', '뒷굽 끝 1.807 m', '교좌면 44.367']) expect(svg).toContain(s);
});
it('모서리: haunch, bracket, slab chamfer and gaps from the inputs', () => {
const svg = abutWingCornerSvg({ parapetMm: 500, bracketMm: 300, wingThicknessMm: 450, haunchMm: 300, slabGapWingMm: 20, slabGapParapetMm: 5, chamferMm: 300 });
for (const s of ['헌치 300 × 300', '브라켓 300 (슬래브 아래)', '슬래브 모따기 300 × 300', '날개벽과 20 · 흉벽과 5', '흉벽 500', '날개벽 450']) expect(svg).toContain(s);
});
});