feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영

- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:11:39 +09:00
1 parent fc0370883f
commit 4b8b80ae97
14 files changed
+854 -60

No files matched your search

+50 -3
View File
@@ -5,7 +5,8 @@
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 { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape';
import { resolveSlab, slabDefaultSlope, slabIssues, type AbutSlab, type SlabForm, type SlabSlope } from './slab';
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI';
@@ -105,7 +106,7 @@ export function restoreAbutDefault(defaults: AbutRecord): AbutRecord {
export function applyShapeEdits(rec: AbutRecord, values: Partial<AbutShape>): AbutRecord {
for (const v of Object.values(values)) if (!Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const shape = { ...(rec.shape ?? {}), ...values };
const issues = shapeIssues(shape);
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, shape };
}
@@ -113,11 +114,57 @@ export function applyShapeEdits(rec: AbutRecord, values: Partial<AbutShape>): Ab
export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const shape = { ...(rec.shape ?? {}), [key]: value };
const issues = shapeIssues(shape);
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, shape };
}
/** 브라켓 높이 (mm) of a shape input, default when absent. */
function bracketHeightMm(shape: Partial<AbutShape> | undefined): number {
return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm;
}
/** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */
function slabFitIssues(slab: Partial<AbutSlab> | undefined, shape: Partial<AbutShape>): string[] {
const s = resolveSlab(slab);
const h = bracketHeightMm(shape);
return s.thicknessMm > h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : [];
}
/** 접속슬래브 형식 (2026-11-04): picking a form also sets its slope (매립형 하향 4 %, 노출형 종단경사). */
export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord {
const slab: Partial<AbutSlab> = { ...(rec.slab ?? {}), form };
delete slab.slope;
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, slab };
}
/** 접속슬래브 경사; the form's own slope is stored as "not set". */
export function setSlabSlope(rec: AbutRecord, slope: SlabSlope): AbutRecord {
const slab: Partial<AbutSlab> = { ...(rec.slab ?? {}), slope };
if (slope === slabDefaultSlope(resolveSlab(rec.slab).form)) delete slab.slope;
return { ...rec, slab };
}
/** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */
export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [
{ key: 'lengthMm', label: '길이 (mm)' },
{ key: 'thicknessMm', label: '두께 (mm)' },
{ key: 'parapetGapMm', label: '흉벽과 간격 (mm)' },
{ key: 'wingGapMm', label: '날개벽과 간격 (mm)' },
{ key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' },
];
/** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */
export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const slab = { ...(rec.slab ?? {}), [key]: value };
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, slab };
}
/** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */
export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [
+5 -2
View File
@@ -54,6 +54,8 @@ export interface AbutInput {
wings?: { left: WingForm; right: WingForm };
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
wingThicknessMm?: { left?: number; right?: number };
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */
slabWingGapMm?: number;
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
@@ -68,7 +70,7 @@ export interface AbutWingModel {
aw01Ok: boolean;
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
slopePct: number;
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(450 / |sin Δ| + 20), 있는 날개벽마다. */
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */
slabWidthMm: number;
wings: { left: WingForm; right: WingForm };
}
@@ -193,7 +195,8 @@ function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide:
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
// 날개벽 두께는 측별 입력 (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 gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM;
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap);
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;
+11 -3
View File
@@ -1,5 +1,6 @@
import type { WingForm } from './geometry';
import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape';
import { resolveSlab, SLAB_FORM_LABEL, slabChangedKeys, slabSlopeLabel, type AbutSlab } from './slab';
/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */
const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm'];
@@ -11,13 +12,13 @@ const FOOTING_KEYS: (keyof AbutShape)[] = [
/** 부재 · 교대 list in ② (시안): the card each row opens. */
export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground';
/** Cards that take input in this step; slab and ground only show their values (phase 1, 2026-11-03). */
/** Cards that take input in this step; ground only shows its values (접속슬래브 입력 2026-11-04). */
export const ABUT_MEMBER_EDITABLE: Record<AbutMemberId, boolean> = {
bearing: true,
shape: true,
footing: true,
wing: true,
slab: false,
slab: true,
ground: false,
};
@@ -28,6 +29,7 @@ export interface AbutMemberInput {
bearings: { seatEl?: number }[];
shape?: Partial<AbutShape>;
wings?: { left: WingForm; right: WingForm };
slab?: Partial<AbutSlab>;
groundEl: number;
}
@@ -48,6 +50,7 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem
const shape = { ...ABUT_SHAPE_DEFAULT, ...changes };
const changed = (keys: (keyof AbutShape)[]) => keys.some((key) => (changes[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key]);
const wings = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
const slab = resolveSlab(rec.slab);
return [
{
id: 'bearing',
@@ -73,7 +76,12 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem
summary: `좌 ${WING_LABEL[wings.left]} · 우 ${WING_LABEL[wings.right]}`,
changed: wings.left !== 'general' || wings.right !== 'general',
},
{ id: 'slab', label: '접속슬래브', summary: slabWidth, changed: false },
{
id: 'slab',
label: '접속슬래브',
summary: `${SLAB_FORM_LABEL[slab.form]} · ${slabSlopeLabel(slab)} · 폭 ${slabWidth}`,
changed: slabChangedKeys(rec.slab).length > 0,
},
{ id: 'ground', label: '지반·지층', summary: `지반고 ${rec.groundEl.toFixed(3)} m`, changed: false },
];
}
+4
View File
@@ -13,6 +13,7 @@ import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
import type { AbutBearingRule } from './bearing-table';
import type { WingDims } from './wings';
import type { AbutSlab } from './slab';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
@@ -59,6 +60,8 @@ export interface AbutRecord {
bearingRule?: Partial<AbutBearingRule>;
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
slab?: Partial<AbutSlab>;
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
@@ -213,6 +216,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput {
slopeI: rec.slopeI,
wings: rec.wings,
wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm },
slabWingGapMm: rec.slab?.wingGapMm,
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
+145
View File
@@ -0,0 +1,145 @@
/**
* 교대 접속슬래브 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드): 형식 · 경사 · 치수 입력과 단면 (t, EL). 순수 함수.
*
* - 매립형 (type-1, 기본): 하면 = 브라켓 상면, 두께 일정. 브라켓 위(벽체 배면까지)는 수평이고 그 뒤로 경사(기본 하향 4 %).
* - 노출형 (type-2, 2026-11-04 삽도 기준, QA-8): 상면 = 흉벽 상단에서 경사(기본 종단경사)를 따른다. 끝은 브라켓 상면에
* 얹히고(끝 두께 = 브라켓 높이), 벽체 배면부터 헌치 길이 동안 두께가 줄어 그 뒤로는 두께가 일정하다.
* - 경사: 형식을 고르면 그 형식의 기본값(매립형 하향 4 %, 노출형 종단경사)으로 바뀌고, 따로 바꿀 수 있다.
* - t: 교대축 직각 (전면 +, 배면 −, m). 슬래브는 흉벽 배면에서 "흉벽과 간격"만큼 띄워 시작하고 길이만큼 배면 쪽으로 간다.
* - 단면의 EL은 그 s의 흉벽 상단 EL에서 잰 값이다 (흉벽 상단 경사는 호출하는 쪽에서 더한다).
*/
export type SlabForm = 'buried' | 'exposed';
export type SlabSlope = 'down4' | 'grade';
export interface AbutSlab {
form: SlabForm;
slope: SlabSlope;
lengthMm: number;
thicknessMm: number;
parapetGapMm: number;
wingGapMm: number;
/** 노출형 헌치 길이 (벽체 배면부터, mm). 매립형은 쓰지 않는다. 기본 2000은 삽도 비율에서 잡은 가정값이다. */
haunchLengthMm: number;
}
export const ABUT_SLAB_DEFAULT: AbutSlab = {
form: 'buried',
slope: 'down4',
lengthMm: 7000,
thicknessMm: 450,
parapetGapMm: 5,
wingGapMm: 20,
haunchLengthMm: 2000,
};
/** 매립형 하향 경사 (%, 배면 쪽으로 내려감). */
export const SLAB_FALL_PCT = 4;
export const SLAB_FORM_LABEL: Record<SlabForm, string> = { buried: '매립형', exposed: '노출형' };
/** The slope a form starts with: 매립형 하향 4 %, 노출형 종단경사 (엑셀 A555 "type-1 = 하향경사 4 %, type-2 = 종단경사"). */
export function slabDefaultSlope(form: SlabForm): SlabSlope {
return form === 'exposed' ? 'grade' : 'down4';
}
/** The user's values over the defaults; the slope follows the form unless it was set. */
export function resolveSlab(changes?: Partial<AbutSlab>): AbutSlab {
const form = changes?.form ?? ABUT_SLAB_DEFAULT.form;
return { ...ABUT_SLAB_DEFAULT, slope: slabDefaultSlope(form), ...(changes ?? {}) };
}
/** Keys whose value differs from the default (형식, 경사 and the dims), for the change marks. */
export function slabChangedKeys(changes?: Partial<AbutSlab>): (keyof AbutSlab)[] {
const s = resolveSlab(changes);
return (Object.keys(ABUT_SLAB_DEFAULT) as (keyof AbutSlab)[]).filter((k) => s[k] !== ABUT_SLAB_DEFAULT[k]);
}
/** Slope (%), + = rises away from the wall: 하향 4 % → −4, 종단경사 → the wing slope (A1 = −i, A2 = +i). */
export function slabGradePct(slab: AbutSlab, wingSlopePct: number): number {
return slab.slope === 'down4' ? -SLAB_FALL_PCT : wingSlopePct;
}
/** Slope text as on the card: "하향 4 %" or "종단경사 +1.91 %". */
export function slabSlopeLabel(slab: AbutSlab, wingSlopePct?: number): string {
if (slab.slope === 'down4') return `하향 ${SLAB_FALL_PCT} %`;
if (wingSlopePct === undefined) return '종단경사';
return `종단경사 ${wingSlopePct >= 0 ? '+' : ''}${wingSlopePct.toFixed(2)} %`;
}
/** Input problems in Korean; empty when the slab can be built. The slab must fit under the 흉벽 상단 on the 브라켓. */
export function slabIssues(s: AbutSlab, bracketHeightMm: number): string[] {
const out: string[] = [];
if (!(s.lengthMm > 0)) out.push('접속슬래브 길이는 0보다 커야 합니다.');
if (!(s.thicknessMm > 0)) out.push('접속슬래브 두께는 0보다 커야 합니다.');
if (!(s.parapetGapMm >= 0)) out.push('흉벽과 간격은 0 이상이어야 합니다.');
if (!(s.wingGapMm >= 0)) out.push('날개벽과 간격은 0 이상이어야 합니다.');
if (s.thicknessMm > bracketHeightMm) out.push(`접속슬래브 두께는 브라켓 높이(${bracketHeightMm}) 이하여야 합니다.`);
if (s.form === 'exposed') {
if (!(s.haunchLengthMm > 0)) out.push('노출형 헌치 길이는 0보다 커야 합니다.');
else if (s.haunchLengthMm >= s.lengthMm) out.push('노출형 헌치 길이는 슬래브 길이보다 짧아야 합니다.');
}
return out;
}
/** One station of the section: t (m) and the top / bottom levels (m, from the 흉벽 상단 at that s). */
export interface SlabStation {
t: number;
top: number;
bottom: number;
}
/** The 벽체·흉벽 values the section needs (m, from the resolved shape). */
export interface SlabShapeRef {
parapetBackM: number;
wallBackM: number;
bracketHeightM: number;
}
/**
* Section stations from the near end (흉벽 쪽) to the far end, at every bend: near end, 벽체 배면, 헌치 끝 (노출형), far end.
* Between stations the top and the bottom are straight, so a strip between two stations is flat on top and below.
*/
export function slabSection(slab: AbutSlab, gradePct: number, sh: SlabShapeRef): SlabStation[] {
const g = gradePct / 100;
const th = slab.thicknessMm / 1000;
const t0 = sh.parapetBackM - slab.parapetGapMm / 1000;
const t1 = t0 - slab.lengthMm / 1000;
const bracket = -sh.bracketHeightM;
const wb = sh.wallBackM;
let level: (t: number) => { top: number; bottom: number };
const marks = [t0, wb, t1];
if (slab.form === 'buried') {
level = (t) => {
const bottom = bracket + g * Math.max(0, wb - t);
return { top: bottom + th, bottom };
};
} else {
const he = wb - slab.haunchLengthMm / 1000;
marks.push(he);
const top = (t: number) => g * (t0 - t);
level = (t) => {
if (t >= wb) return { top: top(t), bottom: bracket };
const full = top(t) - th;
if (t <= he) return { top: top(t), bottom: full };
const k = (wb - t) / (wb - he);
return { top: top(t), bottom: bracket + k * (top(he) - th - bracket) };
};
}
const ts = [...new Set(marks.filter((t) => t <= t0 + 1e-9 && t >= t1 - 1e-9).map((t) => Math.round(t * 1e9) / 1e9))].sort((a, b) => b - a);
return ts.map((t) => ({ t, ...level(t) }));
}
/** Top and bottom at any t inside the slab, by straight lines between the stations. */
export function slabLevelAt(stations: SlabStation[], t: number): { top: number; bottom: number } {
for (let i = 0; i + 1 < stations.length; i++) {
const a = stations[i];
const b = stations[i + 1];
if (t <= a.t + 1e-9 && t >= b.t - 1e-9) {
const k = a.t === b.t ? 0 : (a.t - t) / (a.t - b.t);
return { top: a.top + k * (b.top - a.top), bottom: a.bottom + k * (b.bottom - a.bottom) };
}
}
const end = t > stations[0].t ? stations[0] : stations[stations.length - 1];
return { top: end.top, bottom: end.bottom };
}