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}: 날개벽 형식이 맞지 않습니다.`);