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:
1 parent
a025a61209
commit
80e5302192
12 files changed
+610
-53
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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('교대 기본값 복원과 받침 편집', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user