feat(rebar): 현장타설 말뚝 · 다주형 코핑 형상과 철근 계산

- 현장타설 기둥·말뚝 형상(cast-in-place-shape)과 주철근·횡철근·커플러 계산(rebar/cast-in-place, cast-layout)
- 다주형 코핑 철근(multicol-coping), 기둥 높이 · 양단부 연장
- 철근표 요약 · 재료 수량 · 3D 뷰 연결과 삽도(cast-rebar-svg)
- 검사 기준 갱신과 회귀 테스트, 검증 스크립트 보관

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-06 13:15:01 +09:00
1 parent 3922b330d5
commit 2e0bfb6fc8
29 files changed
+1750 -71

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import type { PierInput } from './types';
/** EL in metres, lengths in mm. User image and clarifications, 2026-11-05. */
export interface CastInPlaceShapeSettings {
betaLengthMm?: number;
weatheredRockEl?: number;
bedrockEl?: number;
casingOn?: boolean;
casingTopEl?: number;
casingBottomEl?: number;
clayBottomEl?: number;
}
export function castShape(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape'>) {
const s = input.castInPlaceShape ?? {};
const pileBottomEl = input.groundEl - input.pileLengthMm / 1000;
const bedrockEl = s.bedrockEl ?? pileBottomEl + 1;
const betaLengthMm = s.betaLengthMm ?? 9000;
return {
pileTopEl: input.groundEl, pileBottomEl,
betaLengthMm, betaEl: input.groundEl - betaLengthMm / 1000,
weatheredRockEl: s.weatheredRockEl ?? pileBottomEl + 5,
bedrockEl,
casingOn: s.casingOn ?? false,
casingTopEl: s.casingTopEl ?? input.groundEl + 3,
casingBottomEl: s.casingBottomEl ?? bedrockEl,
clayBottomEl: s.clayBottomEl,
};
}
export function castShapeIssues(value: unknown): string[] {
if (value === undefined) return [];
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 형상 입력 형식이 맞지 않습니다.'];
const out: string[] = [];
for (const [key, v] of Object.entries(value)) {
if (v === undefined) continue;
if (key === 'casingOn') { if (typeof v !== 'boolean') out.push('희생강관 적용 여부가 맞지 않습니다.'); }
else if (!['betaLengthMm', 'weatheredRockEl', 'bedrockEl', 'casingTopEl', 'casingBottomEl', 'clayBottomEl'].includes(key) || typeof v !== 'number' || !Number.isFinite(v) || key === 'betaLengthMm' && v <= 0) out.push(`현장타설 형상 ${key} 값이 맞지 않습니다.`);
}
return out;
}
/** DXF note 4. Do not substitute weathered-rock EL for the independent clay-layer EL. */
export function castLowerZone(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape' | 'columnDiameterMm'>) {
const s = castShape(input);
if (s.clayBottomEl === undefined) return null;
const d = (input.columnDiameterMm ?? 2000) / 1000;
const a = s.betaEl - 3 * d;
const b = s.clayBottomEl + d;
return { bottomEl: a, topEl: b, valid: a < b };
}
+3 -1
View File
@@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [
] as const;
/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar'] as const;
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar', 'castInPlaceRebar', 'castInPlaceShape'] as const;
export type EditableKey = (typeof TRACKED_KEYS)[number];
@@ -59,6 +59,8 @@ export const FIELD_LABELS: Record<EditableKey, string> = {
columnRebar: '기둥 배근 입력',
footingRebar: '확대기초 배근 입력',
strataRows: '지층 행',
castInPlaceRebar: '현장타설 기둥·말뚝 배근 입력',
castInPlaceShape: '현장타설 암반·고정점·희생강관 입력',
};
/** Keys whose values differ between two inputs. Arrays are compared by value. */
+16 -10
View File
@@ -1,13 +1,14 @@
import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block';
import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block';
import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
import { type CopingRebarSettings, copingDefaults, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings';
import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings';
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
import type { PierInput, Row, SolplatePlan } from './types';
import { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
import { castShape } from './cast-in-place-shape';
/** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
export const DIM = {
@@ -15,7 +16,7 @@ export const DIM = {
copingH1: 1.0,
copingH2: 1.5,
levelWidth: 2.1,
/** Multi-column coping: underside between columns sits this far below Level, with 45° haunches of the same size (spec §4.5, Q3 추천안). */
/** Multi-column coping: underside between columns sits this far above Level, with 45° haunches of the same size (spec §4.4, Q3). */
haunch: 0.3,
padThickness: 0.05,
padSize: 1.0,
@@ -207,6 +208,8 @@ export interface PierModel {
blockHeightBack: number;
frontBackDiffSpread: number;
};
/** Dedicated rebar source; never substitute footing-anchored column inputs. */
castInput?: PierInput;
}
/**
@@ -343,7 +346,7 @@ export function buildPier(input: PierInput): PierModel {
const levelEnd = Math.max(...colG) + levelW / 2;
if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.');
// Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3).
// Underside from the right end to the left end. Between columns (2·3주형) it rises 300 above Level with 300 × 300 haunches (spec §4.4, Q3).
const underside: [number, number][] = [[L, endBottom]];
const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const);
levelZones.forEach(([zoneStart, zoneEnd], k) => {
@@ -352,8 +355,8 @@ export function buildPier(input: PierInput): PierModel {
if (!next) return;
const gap = zoneStart - next[1];
if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.');
const lowerEl = levelBottom - DIM.haunch;
underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]);
const raisedEl = levelBottom + DIM.haunch;
underside.push([zoneStart - DIM.haunch, raisedEl], [next[1] + DIM.haunch, raisedEl], [next[1], levelBottom]);
});
// Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
@@ -394,7 +397,9 @@ export function buildPier(input: PierInput): PierModel {
}
}
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + fd.H;
const cast = castShape(input);
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H;
const columns: ColumnModel[] = colG.map((g, i) => ({
s: g - base[colK[i]],
k: colK[i],
@@ -407,11 +412,11 @@ export function buildPier(input: PierInput): PierModel {
const blindings: BlindingModel[] = [];
const piles: PileModel[] = [];
const pileLength = input.pileLengthMm / 1000;
const pileTip = input.footingBottomEl - pileLength;
const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength;
if (input.foundationType === '현장타설말뚝') {
columns.forEach((c) => {
piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: colD });
piles.push({ s: c.s, k: c.k, n: 0, top: cast.pileTopEl, bottom: pileTip, diameter: colD });
});
} else {
for (const f of footingSets) {
@@ -487,7 +492,7 @@ export function buildPier(input: PierInput): PierModel {
blocks,
integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'),
blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar },
copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar },
copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar },
topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice },
columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar },
footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar },
@@ -498,7 +503,7 @@ export function buildPier(input: PierInput): PierModel {
groundEl: input.groundEl,
derived: {
columnHeight: levelBottom - columns[0].bottom,
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop),
soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop,
pileCount: piles.length,
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null,
pileTipEl: piles.length ? pileTip : null,
@@ -506,6 +511,7 @@ export function buildPier(input: PierInput): PierModel {
blockHeightBack,
frontBackDiffSpread,
},
...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}),
};
}
+6
View File
@@ -4,6 +4,8 @@ import type { PierInput } from './types';
import type { AbutRecord } from './abut/parse';
import { abutNameError } from './abut/name';
import { normalizeBearingAzimuths } from './coordinate-frame';
import { castSettingsIssues } from './rebar/cast-in-place';
import { castShapeIssues } from './cast-in-place-shape';
/** Project file schema (spec §8). Bump only with a migration. */
export const SCHEMA_VERSION = 1;
@@ -95,6 +97,10 @@ export function parseProject(text: string): ProjectFile {
}
}
for (const values of [p.input, p.defaults, p.draft]) {
const shapeIssues = castShapeIssues(values?.castInPlaceShape);
if (shapeIssues.length) throw new Error(`교각 ${input.name}: ${shapeIssues.join(' · ')}`);
const castIssues = castSettingsIssues(values?.castInPlaceRebar);
if (castIssues.length) throw new Error(`교각 ${input.name}: 현장타설 배근 · ${castIssues.join(' · ')}`);
if (values?.bearingAzimuthFrame !== undefined && values.bearingAzimuthFrame !== 'model-xy') {
throw new Error(`교각 ${input.name}: 받침 방위각 좌표계 표기가 맞지 않습니다.`);
}
+106
View File
@@ -0,0 +1,106 @@
import type { PierInput } from '../types';
import { DEFAULT_COLUMN_REBAR } from './column-settings';
import { NOMINAL_DIAMETER, DEFAULT_MATERIAL, type Material } from './common';
/** Dedicated settings: round-column defaults were authorised by the user on 2026-11-05. */
export interface CastRebarPart {
mainDiameterMm?: number;
mainCount?: number;
mainGradeMpa?: number;
coverMm?: number;
concreteStrengthMpa?: number;
tieDiameterMm?: number;
tieGradeMpa?: number;
tieSpacingMm?: number;
endZoneLengthMm?: number;
endZoneSpacingMm?: number;
cageLengthMm?: number;
endCoverMm?: number;
topEmbedmentMm?: number;
tieKind?: 'hoop' | 'spiral';
coverReference?: 'clear' | 'main-center';
innerMainDiameterMm?: number;
innerMainCount?: number;
}
export interface CastInPlaceRebar {
column?: CastRebarPart;
pile?: CastRebarPart;
connection?: {
mode?: 'continuous' | 'lap' | 'coupler';
lapLengthMm?: number;
couplerLengthMm?: number;
};
}
export const CAST_PART_FIELDS: { key: Exclude<keyof CastRebarPart, 'tieKind' | 'coverReference'>; label: string; only?: 'column' | 'pile' }[] = [
{ key: 'mainDiameterMm', label: '외측 주철근 공칭지름 (mm)' },
{ key: 'mainCount', label: '외측 주철근 본수 (본)' },
{ key: 'mainGradeMpa', label: '주철근 강도 fy (MPa)' },
{ key: 'coverMm', label: '측면 피복 (아래 기준 선택, mm)' },
{ key: 'concreteStrengthMpa', label: '콘크리트 강도 fck (MPa)' },
{ key: 'tieDiameterMm', label: '띠·나선철근 직경 (mm)' },
{ key: 'tieGradeMpa', label: '띠·나선철근 강도 fy (MPa)' },
{ key: 'tieSpacingMm', label: '일반구간 띠 간격·나선 피치 (mm)' },
{ key: 'endZoneLengthMm', label: '단부 보강구간 길이 (mm)' },
{ key: 'endZoneSpacingMm', label: '단부 보강구간 간격 (mm)' },
{ key: 'cageLengthMm', label: '말뚝 철근망 길이 (mm)', only: 'pile' },
{ key: 'endCoverMm', label: '말뚝 선단 순피복 (mm)', only: 'pile' },
{ key: 'topEmbedmentMm', label: '코핑 내부 주철근 연장 길이 (mm)', only: 'column' },
{ key: 'innerMainDiameterMm', label: '내측 주철근 공칭지름 (mm)' },
{ key: 'innerMainCount', label: '내측 주철근 본수 (본)' },
];
export function castRebarDefaults(input: Pick<PierInput, 'columnRebar' | 'castInPlaceRebar'>, material: Material = DEFAULT_MATERIAL): CastInPlaceRebar {
const c = { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar };
const saved = input.castInPlaceRebar ?? {};
const part = (key: 'column' | 'pile'): CastRebarPart => ({
mainDiameterMm: NOMINAL_DIAMETER.S32, mainCount: c.nDiv, mainGradeMpa: material.grade.S,
innerMainDiameterMm: NOMINAL_DIAMETER.S32, innerMainCount: c.nDiv,
coverMm: c.c, coverReference: saved[key]?.coverMm !== undefined && saved[key]?.coverReference === undefined ? 'clear' : 'main-center',
concreteStrengthMpa: material.fck,
tieDiameterMm: NOMINAL_DIAMETER.H19, tieGradeMpa: material.grade.H, tieSpacingMm: c.ctcP3,
endZoneSpacingMm: c.ctcP5, tieKind: 'hoop',
...(key === 'pile' ? { endCoverMm: 250 } : {}),
...saved[key],
});
return { column: part('column'), pile: part('pile'), connection: { mode: 'coupler', couplerLengthMm: 150, ...saved.connection } };
}
export const CAST_PENDING = ['현장타설 기둥 배근 상세 미확정', '현장타설말뚝 배근 상세 미확정', '기둥·말뚝 접합부 배근 상세 미확정'];
/** Storage and input validation; domain-specific spacing/anchorage rules remain unconfirmed. */
export function castSettingsIssues(value: unknown, pileLengthMm?: number, diameterMm?: number): string[] {
if (value === undefined) return [];
const issues: string[] = [];
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 배근 입력 형식이 맞지 않습니다.'];
const record = value as Record<string, unknown>;
if (Object.keys(record).some(k => !['column', 'pile', 'connection'].includes(k))) issues.push('알 수 없는 현장타설 배근 항목입니다.');
for (const part of ['column', 'pile', 'connection'] as const) {
const values = record[part];
if (values === undefined) continue;
if (!values || typeof values !== 'object' || Array.isArray(values)) { issues.push(`${part}: 입력 형식이 맞지 않습니다.`); continue; }
for (const [key, v] of Object.entries(values)) {
if (v === undefined) continue;
if (part === 'connection' && key === 'mode') {
if (typeof v !== 'string' || !['continuous', 'lap', 'coupler'].includes(v)) issues.push('접합 방식이 맞지 않습니다.');
} else if (part !== 'connection' && key === 'tieKind') {
if (typeof v !== 'string' || !['hoop', 'spiral'].includes(v)) issues.push('횡철근 형식이 맞지 않습니다.');
} else if (part !== 'connection' && key === 'coverReference') {
if (typeof v !== 'string' || !['clear', 'main-center'].includes(v)) issues.push('피복 기준이 맞지 않습니다.');
} else {
const allowed = part === 'connection' ? ['lapLengthMm', 'couplerLengthMm'] : CAST_PART_FIELDS.filter(f => !f.only || f.only === part).map(f => f.key);
if (!allowed.includes(key) || typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || (key.endsWith('Count') && !Number.isInteger(v))) issues.push(`${part}.${key}: 양수${key.endsWith('Count') ? ' 정수' : ''}로 입력하세요.`);
}
}
if (part === 'pile') {
const p = values as CastRebarPart;
if (pileLengthMm !== undefined && p.cageLengthMm !== undefined && p.cageLengthMm > pileLengthMm) issues.push('말뚝 철근망 길이가 말뚝 길이보다 큽니다.');
}
if (part !== 'connection') {
const p = values as CastRebarPart;
if (diameterMm !== undefined && p.coverMm !== undefined && p.coverMm * 2 >= diameterMm) issues.push(`${part}: 피복이 단면 반지름 이상입니다.`);
}
}
return issues;
}
+222
View File
@@ -0,0 +1,222 @@
import type { PierModel } from '../geometry';
import { castLowerZone } from '../cast-in-place-shape';
import { castRebarDefaults, castSettingsIssues, type CastRebarPart } from './cast-in-place';
import { DEFAULT_MATERIAL, developmentLength, hookLength, spliceLength, type Material } from './common';
export type CastPoint = [number, number, number];
/** One physical piece. Coordinates use the existing rebar scene frame, in metres. */
export interface CastBar {
name: string;
member: 'column' | 'pile';
kind: 'main' | 'tie' | 'crosstie' | 'coupler';
k: number;
pts: CastPoint[];
diameterMm: number;
gradeMpa: number;
lengthMm: number;
note: string;
columnIndex: number;
}
export interface CastLayout { bars: CastBar[]; missing: string[] }
/** Shared by the renderer, drawings and quantities; no meshes or UI state here. */
export function castRebarLayout(model: PierModel, material: Material = DEFAULT_MATERIAL): CastLayout {
const bars: CastBar[] = [];
const missing = new Set<string>();
if (!model.castInput) return { bars, missing: [] };
const input = model.castInput;
const saved = castRebarDefaults(input, material);
const issues = castSettingsIssues(saved, input.pileLengthMm, model.columnDiameterMm);
if (issues.length) return { bars, missing: issues };
const parts = ['column', 'pile'] as const;
const names = { column: '기둥', pile: '말뚝' };
const joint = model.groundEl;
const D = model.columnDiameterMm;
const pitch = model.columnRebar.ctcCoupler;
const stagger = model.columnRebar.dzCp2;
const mode = saved.connection!.mode;
const couplingLength = saved.connection!.couplerLengthMm;
const lap = saved.connection!.lapLengthMm;
let owner = 0;
const add = (bar: Omit<CastBar, 'columnIndex'>) => {
if (bar.lengthMm > 0 && Number.isFinite(bar.lengthMm)) bars.push({ ...bar, columnIndex: owner });
};
const radii = (p: CastRebarPart) => {
const outer = D / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0);
return { outer, inner: outer - (p.mainDiameterMm! + p.innerMainDiameterMm!) / 2, tie: outer + (p.mainDiameterMm! + p.tieDiameterMm!) / 2 };
};
const r = { column: radii(saved.column!), pile: radii(saved.pile!) };
const aligned = (['outer', 'inner'] as const).every((layer, i) => {
const count = i ? 'innerMainCount' : 'mainCount';
const diameter = i ? 'innerMainDiameterMm' : 'mainDiameterMm';
return saved.column![count] === saved.pile![count] && saved.column![diameter] === saved.pile![diameter]
&& saved.column!.mainGradeMpa === saved.pile!.mainGradeMpa && Math.abs(r.column[layer] - r.pile[layer]) < 0.01;
});
if (!aligned) missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산');
const connected = aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength || mode === 'lap' && !!lap);
if (!mode || mode === 'coupler' && !couplingLength || mode === 'lap' && !lap) missing.add('접합부 연결 방식 또는 연결 길이 미입력');
if (mode === 'lap') missing.add('겹침이음의 횡방향 배치 상세 미확정 · 겹침부 미계산');
missing.add('말뚝 저부 P6 검토안 미적용');
model.columns.forEach((column, columnIndex) => {
owner = columnIndex;
const pile = model.piles[columnIndex];
if (!pile) { missing.add('기둥에 대응하는 현장타설말뚝 없음'); return; }
const g = column.s + model.stretches[column.k].base;
const segment = model.segments.find(s => g >= s.s0 && g <= s.s1) ?? model.segments[0];
const c3 = model.copingRefTop - 0.127 - 2 * model.copingRebar.mainLayerGap / 1000;
const point = (radius: number, angle: number, height: number): CastPoint => [column.s + radius / 1000 * Math.cos(angle), height, radius / 1000 * Math.sin(angle)];
const bottomCover = saved.pile!.endCoverMm!;
const mainRadius = Math.max(saved.pile!.mainDiameterMm!, saved.pile!.innerMainDiameterMm!) / 2;
const cageBottom = saved.pile!.cageLengthMm === undefined ? pile.bottom + (bottomCover + mainRadius) / 1000 : joint - saved.pile!.cageLengthMm / 1000;
if (cageBottom < pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 || cageBottom >= joint) {
missing.add('말뚝 철근망 하단이 선단 피복 또는 말뚝 범위를 벗어남');
}
const pileValid = cageBottom >= pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 && cageBottom < joint;
const ext = saved.column!.topEmbedmentMm;
const tops = [ext === undefined ? c3 - 0.1 : column.top + ext / 1000,
column.top + (ext ?? developmentLength(saved.column!.innerMainDiameterMm!, saved.column!.mainGradeMpa!, saved.column!.concreteStrengthMpa!)) / 1000];
const validRadii = (part: 'column' | 'pile') => {
const p = saved[part]!;
return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= D / 2 + 1e-8;
};
for (const part of parts) {
if (!validRadii(part)) { missing.add(`${names[part]} 철근 단면·피복 범위 오류`); continue; }
if (part === 'pile' && !pileValid) continue;
const p = saved[part]!;
if (p.mainCount! > 2000 || p.innerMainCount! > 2000) { missing.add(`${names[part]} 주철근 본수 표시 한도 초과`); continue; }
for (const [layerIndex, layer] of (['outer', 'inner'] as const).entries()) {
const diameter = layerIndex ? p.innerMainDiameterMm! : p.mainDiameterMm!;
const count = layerIndex ? p.innerMainCount! : p.mainCount!;
const top = tops[layerIndex];
if (part === 'column' && (top <= joint || top + diameter / 2000 > segment.top)) {
missing.add(`${names[part]} P${layerIndex + 1} 상단 정착이 코핑 범위를 벗어남`); continue;
}
const offset = layerIndex ? stagger / 1000 : 0;
const boundary = connected && mode === 'coupler' ? joint + offset : joint;
const start = part === 'column' ? boundary : cageBottom;
const end = part === 'column' ? top : boundary;
const combined = connected && mode === 'continuous' && pileValid && validRadii('pile');
if (combined) missing.add('연속 주철근 제작·시공 이음 상세 미확정');
if (combined && part === 'pile') continue;
const lo = combined ? cageBottom : start;
if (end <= lo) { missing.add(`${names[part]} 주철근 유효 길이 없음`); continue; }
const cuts: number[] = [];
// Periodic column joints reuse the existing approved pitch and stagger; no footing reference.
if (mode === 'coupler' && couplingLength && pitch > 0) {
for (let h = joint + offset + pitch / 1000; h < end - 2.5; h += pitch / 1000) if (h > lo) cuts.push(h);
for (let h = joint + offset - pitch / 1000; h > lo + couplingLength / 1000; h -= pitch / 1000) if (h < end) cuts.push(h);
}
const levels = [lo, ...cuts.sort((a, b) => a - b), end];
for (let i = 0; i < count; i++) {
const angle = 2 * Math.PI * i / count;
const name = `${combined ? '기둥·말뚝' : names[part]}-P${layerIndex + 1}`;
levels.slice(1).forEach((h, j) => add({ name, member: part, kind: 'main', k: column.k,
pts: [point(r[part][layer], angle, levels[j]), point(r[part][layer], angle, h)], diameterMm: diameter,
gradeMpa: p.mainGradeMpa!, lengthMm: (h - levels[j]) * 1000, note: combined ? '연속 주철근 · 기초 갈고리 없음' : '현장타설 전용 주철근 · 마디별 집계' }));
const coupler = (h: number, label: string) => {
if (couplingLength! > pitch || h - couplingLength! / 2000 < cageBottom || h + couplingLength! / 2000 > top) {
missing.add('주철근 커플러 길이가 배치 가능 범위를 벗어남'); return;
}
if (Math.abs(diameter - 31.8) > 0.05) missing.add('주철근 커플러 외경은 표시 가정 · 실제 규격 확인 필요');
add({ name: label, member: part, kind: 'coupler', k: column.k,
pts: [point(r[part][layer], angle, h - couplingLength! / 2000), point(r[part][layer], angle, h + couplingLength! / 2000)],
diameterMm: diameter * 60 / 31.8, gradeMpa: p.mainGradeMpa!, lengthMm: couplingLength!, note: '주철근 중심 맞댐 · 외경은 기존 D32용 비율의 표시 가정' });
};
for (const h of cuts) coupler(h, `${names[part]}-P${layerIndex + 1} 커플러`);
if (part === 'column' && connected && mode === 'coupler' && pileValid && validRadii('pile')) coupler(boundary, `접합-P${layerIndex + 1} 커플러`);
}
}
const upper = part === 'column' ? (column.top + segment.top) / 2 : joint;
const lower = part === 'column' ? joint : cageBottom + p.tieDiameterMm! / 2000;
let zone: [number, number] | undefined;
if (part === 'column' && p.endZoneLengthMm !== undefined) zone = [Math.max(lower, column.top - p.endZoneLengthMm / 1000), upper];
if (part === 'pile') {
const geo = castLowerZone(input);
const bottom = joint - (input.castInPlaceShape?.betaLengthMm ?? 9000) / 1000 - 3 * D / 1000;
if (p.endZoneLengthMm !== undefined) zone = [bottom, bottom + p.endZoneLengthMm / 1000];
else if (geo?.valid) zone = [geo.bottomEl, geo.topEl];
else if (geo) missing.add('말뚝 하단 단부구역 경계 역전');
}
if (!zone) missing.add(`${names[part]} 단부구간 경계 미입력 · 일반 횡철근만 계산`);
if (zone && (zone[0] < lower || zone[1] > upper || zone[1] <= zone[0])) {
missing.add(`${names[part]} 단부구간이 철근망 범위를 벗어남`); zone = undefined;
}
const radius = r[part].tie;
const tieNote = '일반/단부 경계에서 구간별 반간격 시작 · 겹침 원형 이음';
const intervals: { from: number; to: number; step: number; mark: string }[] = [];
const ordinary = (from: number, to: number) => { if (to > from) intervals.push({ from, to, step: p.tieSpacingMm!, mark: 'P3' }); };
const zones = zone ? [zone] : [];
// A column upper-end region that crosses ground continues on the pile side, with pile-section properties.
const columnEnd = saved.column!.endZoneLengthMm;
if (part === 'pile' && columnEnd !== undefined && column.top - columnEnd / 1000 < joint) {
zones.push([Math.max(lower, column.top - columnEnd / 1000), upper]);
}
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const next of zones) {
const last = merged.at(-1);
if (last && next[0] <= last[1]) last[1] = Math.max(last[1], next[1]);
else merged.push([...next]);
}
let cursor = lower;
for (const dense of merged) {
ordinary(cursor, dense[0]); intervals.push({ from: dense[0], to: dense[1], step: p.endZoneSpacingMm!, mark: 'P5' }); cursor = dense[1];
}
ordinary(cursor, upper);
for (const interval of intervals) {
const { from, to, step, mark } = interval;
if (step <= 0 || !Number.isFinite(step)) continue;
if ((to - from) * 1000 / step > 5000) { missing.add(`${names[part]} 횡철근 개수 과다 · 간격 확인 필요`); continue; }
const lapLength = mark === 'P5' ? 0 : spliceLength(p.tieDiameterMm!, p.tieGradeMpa!, p.concreteStrengthMpa!);
if (lapLength >= 2 * Math.PI * radius) { missing.add(`${names[part]} 횡철근 겹침길이가 원주 이상 · 재료 입력 확인 필요`); continue; }
const makeTie = (pts: CastPoint[], lengthMm: number, note: string) => add({ name: `${names[part]}-${mark}${p.tieKind === 'spiral' ? ' 나선' : ''}`,
member: part, kind: 'tie', k: column.k, pts, diameterMm: p.tieDiameterMm!, gradeMpa: p.tieGradeMpa!, lengthMm, note });
const a = from + step / 2000;
const b = to - p.tieDiameterMm! / 2000;
if (b <= a) { missing.add(`${names[part]} 횡철근 구간 길이 부족`); continue; }
if (p.tieKind === 'spiral') {
const turns = (b - a) * 1000 / step;
const n = Math.ceil(turns * 32);
const pts = Array.from({ length: n + 1 }, (_, j) => point(radius, 2 * Math.PI * turns * j / n, a + (b - a) * j / n));
makeTie(pts, Math.hypot(2 * Math.PI * radius * turns, (b - a) * 1000), '나선 중심선 · 단부 정착·구간 전이 상세 미확정');
missing.add(`${names[part]} 나선철근 단부 정착·구간 전이 미확정`);
} else {
const totalAngle = 2 * Math.PI + lapLength / radius;
for (let h = a, index = 0; h <= b + 1e-9; h += step / 1000, index++) {
const pts = Array.from({ length: 65 }, (_, j) => point(radius, index % 2 * Math.PI + totalAngle * j / 64, h));
makeTie(pts, 2 * Math.PI * radius + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : tieNote);
if (mark === 'P5') {
const angle = index % 2 * Math.PI + Math.PI / p.mainCount!;
const half = 50 / radius;
add({ name: `${names[part]}-P5 띠철근 커플러`, member: part, kind: 'coupler', k: column.k,
pts: [point(radius, angle - half, h), point(radius, angle + half, h)], diameterMm: 40,
gradeMpa: p.tieGradeMpa!, lengthMm: 100, note: '기존 H19용 L100·Ø40 폐합 상세' });
if (Math.abs(p.tieDiameterMm! - 19.1) > 0.05) missing.add(`${names[part]} 단부 띠철근 커플러 규격 확인 필요`);
}
}
}
}
// Only the column crossties are reused. Pile-bottom U bars remain a separate pending design.
if (part === 'column') {
if (p.mainCount! < 8 || p.mainCount! % 2) { missing.add('기둥 P6 대칭쌍 배치에는 8본 이상 짝수 외측 주철근이 필요'); continue; }
const diameter = 15.9;
const hook = Math.round(hookLength('tie90', diameter));
for (let h = lower + Math.max(p.tieSpacingMm! / 2, hook + diameter / 2) / 1000; h + 3.5 * diameter / 1000 <= upper; h += model.columnRebar.ctcP6 / 1000) {
for (let j = 0; j < 4; j++) {
const index = Math.round(j * p.mainCount! / 8);
const angle = index * 2 * Math.PI / p.mainCount!;
const y = h + j * diameter / 1000;
const a = point(radius, angle, y), b = point(radius, angle + Math.PI, y);
add({ name: '기둥-P6 크로스타이', member: part, kind: 'crosstie', k: column.k,
pts: [[a[0], a[1] - hook / 1000, a[2]], a, b, [b[0], b[1] - hook / 1000, b[2]]],
diameterMm: diameter, gradeMpa: material.grade.H, lengthMm: 2 * radius + 2 * hook,
note: '기둥 크로스타이 @기존 P6간격 · 말뚝 저부 P6와 별개' });
}
}
missing.add('기둥 P6 시작단·적층·굽힘 상세 확인 필요');
}
}
});
return { bars, missing: [...missing] };
}
+1
View File
@@ -5,6 +5,7 @@ export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'othe
/** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */
export function memberOfBar(name: string): RebarMemberKey {
if (/^(기둥|말뚝|접합)/.test(name)) return 'column';
if (name.includes('커플러') || /^P\d/.test(name)) return 'column';
if (/^C\d/.test(name)) return 'coping';
if (/^B\d/.test(name)) return 'footing';
+23 -9
View File
@@ -39,6 +39,8 @@ export interface ColumnInput {
/** P4 구간 깊이 비율 r_P4. */
rP4: number;
ctcP6: number;
/** 다주형 삽도의 상·하단부 합집합. 번호는 기존 역할 대응을 유지한다. */
bothEndZones?: boolean;
}
/** 예제 운곡교 P1 (기둥철근.md §1.2 기본값). */
@@ -47,18 +49,22 @@ export interface ColumnInput {
* h_cop = 계단 상면(기둥 중심 구간의 상면) − Level 하면 (2.644 on P1, 43.555 − 40.911). Only these two follow the model;
* the other values stay as in COLUMN_P1.
*/
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1): ColumnInput {
const col = model.columns[0];
const seg = model.segments.find((g) => col.s >= g.s0 - 1e-9 && col.s <= g.s1 + 1e-9) ?? model.segments[0];
const Hc = Math.round((model.levelBottom - model.footings[0].top) * 1000 * 10) / 10;
const hcop = Math.round((seg.top - model.levelBottom) * 1000 * 10) / 10;
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1, columnIndex = 0): ColumnInput {
if (!model.footings.length) throw new Error('현장타설말뚝 기둥은 전용 배근 입력을 사용합니다. 확대기초 정착 규칙은 적용하지 않습니다.');
const col = model.columns[columnIndex];
const globalS = col.s + model.stretches[col.k].base;
const seg = model.segments.find((g) => globalS >= g.s0 - 1e-9 && globalS <= g.s1 + 1e-9) ?? model.segments[0];
const footing = model.footings.find(f => f.k === col.k && Math.abs(f.s - col.s) <= f.length / 2)!;
const Hc = Math.round((col.top - col.bottom) * 1000 * 10) / 10;
const hcop = Math.round((seg.top - col.top) * 1000 * 10) / 10;
// C3 (상면 3단) = the lowest coping top −127 (C1 cover) −2·s_lay (상면 주철근 단 간격 input); P1 was 43.170 at 57.2.
// P1 top = C3 − 100 (계산 2026-10-23), so e_P1 = step top − (C3 − 0.1) = 485 on P1.
const c3 = model.copingRefTop - 0.127 - 2 * (model.copingRebar.mainLayerGap / 1000);
const eP1 = Math.round((seg.top - (c3 - 0.1)) * 1000);
// e_top: the coping middle level (Level 하면 + 계단 상면)/2 is the top of P3 and P6 (승인 2026-10-23).
const eTop = Math.round((seg.top - (model.levelBottom + seg.top) / 2) * 1000);
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm };
const eTop = Math.round((seg.top - (col.top + seg.top) / 2) * 10000) / 10;
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm,
Hf: (footing.top - footing.bottom) * 1000, bothEndZones: model.columns.length > 1 };
}
export const COLUMN_P1: ColumnInput = {
@@ -142,9 +148,17 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA
const lEmin = Math.max(x.D, zTopT / 6, 450);
const n5 = Math.floor(lEmin / x.ctcP5);
const lE = Math.max(lEmin, n5 * x.ctcP5 + x.ctcP5 / 2);
const p3 = Array.from({ length: Math.floor((zTopT - lE) / x.ctcP3) + 1 }, (_, k) => lE + k * x.ctcP3);
let p3 = Array.from({ length: Math.max(0, Math.floor((zTopT - lE) / x.ctcP3) + 1) }, (_, k) => lE + k * x.ctcP3);
const n5all = Math.floor(lE / x.ctcP5);
const p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
let p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
if (x.bothEndZones) {
// Drawing priority: at least D below the coping, plus half the coping height above it.
// Existing lower-end minimum is retained. Overlapping ends become one dense interval.
const upperStart = Math.max(0, x.Hc - lE);
const intervals = upperStart <= lE ? [[0, zTopT]] : [[0, lE], [upperStart, zTopT]];
p5 = intervals.flatMap(([a, b]) => Array.from({ length: Math.max(0, Math.floor((b - a) / x.ctcP5)) }, (_, i) => a + x.ctcP5 / 2 + i * x.ctcP5));
p3 = upperStart <= lE ? [] : Array.from({ length: Math.max(0, Math.floor((upperStart - lE) / x.ctcP3)) }, (_, i) => lE + x.ctcP3 / 2 + i * x.ctcP3);
}
const z5last = lE - n5all * x.ctcP5;
const m4 = Math.floor((z5last + x.rP4 * x.Hf) / x.ctcP4);
const p4All = Array.from({ length: m4 }, (_, j) => z5last - (j + 1) * x.ctcP4);
+8 -2
View File
@@ -44,6 +44,12 @@ export const DEFAULT_COPING_REBAR: CopingRebarSettings = {
c14LowerSpacing: 2000,
};
/** 1106 ‘2주형 3주형’ 전용 삽도 우선(사용자 선택). 명시 입력은 이 기본값보다 우선한다. */
export function copingDefaults(columnCount: number): CopingRebarSettings {
return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, spacingS: 300, spacingL: 300,
sideSpacing: 200, c14UpperSpacing: 600, c14LowerSpacing: 600 } : { ...DEFAULT_COPING_REBAR };
}
/** Allowed range per input (inclusive, mm). The shear legs are checked for evenness separately (CP02). */
export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, number]> = {
cover: [80, 150],
@@ -56,8 +62,8 @@ export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, num
spacingL: [200, 350],
sideSpacing: [200, 300],
sideEndGap: [300, 500],
c14UpperSpacing: [800, 1200],
c14LowerSpacing: [1500, 2500],
c14UpperSpacing: [600, 1200],
c14LowerSpacing: [600, 2500],
};
/** 상면 주철근(C1–C6) 이음 (승인 2026-10-26): ignore = 이음 무시 (사양 기본), apply = 정척 초과 시 2조각 겹침. */
+136
View File
@@ -0,0 +1,136 @@
import type { PierModel } from '../geometry';
import { sideRowLevels } from '../coping-geom';
import { DEFAULT_MATERIAL, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
import { widthLayout } from './coping-main';
import { C14_INSET, SIDE_HOOK_LEG, stairBars } from './coping';
export type CopingPoint = [number, number, number];
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
export function copingEnvelope(model: PierModel) {
const polygons = model.copingParts.filter(p => p.k === 0).map(p => p.profile);
const xs = [...new Set(polygons.flatMap(p => p.map(v => v[0])))].sort((a, b) => a - b);
const under = (x: number) => Math.max(...polygons.map(p => {
const hits = p.flatMap((a, i) => {
const b = p[(i + 1) % p.length];
if (Math.abs(b[0] - a[0]) < 1e-9 || x < Math.min(a[0], b[0]) - 1e-9 || x > Math.max(a[0], b[0]) + 1e-9) return [];
return [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])];
});
return Math.min(...hits);
}));
const slopes = xs.slice(1).map((x, i) => (under(x) - under(xs[i])) / (x - xs[i]));
// Conservative vertical inset at a corner: both adjoining faces retain the requested normal cover.
const inset = (x: number, cover: number) => under(x) + cover * Math.max(1, ...slopes.filter((_, i) => x >= xs[i] - 1e-9 && x <= xs[i + 1] + 1e-9).map(s => Math.hypot(1, s)));
return { xs, under, inset };
}
export function copingPathLength(pts: CopingPoint[]): number {
return pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - pts[i][j])), 0) * 1000;
}
/** Shared physical pieces for multi-column 3D and quantities. T-shaped legacy layout is unchanged. */
export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] {
const bars: CopingBar[] = [];
const L = model.copingL, B = model.copingB, top = model.copingRefTop;
const settings = model.copingRebar;
if ([settings.spacingS, settings.spacingL, settings.sideSpacing, settings.c14UpperSpacing, settings.c14LowerSpacing].some(v => !Number.isFinite(v) || v <= 0)
|| !Number.isInteger(settings.shearLegs) || settings.shearLegs < 2 || settings.shearLegs > 8 || settings.shearLegs % 2) {
throw new Error('다주형 배근 간격은 양수, 전단 다리 수는 2~8의 짝수여야 합니다.');
}
const envelope = copingEnvelope(model);
const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => {
if (pts.length < 2) return;
bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) });
};
const width = widthLayout(B * 1000, settings);
const ns = [width.yE, ...Array.from({ length: width.n + 1 }, (_, i) => width.yE + width.g + width.spacing * i), B * 1000 - width.yE].map(v => v / 1000 - B / 2);
// C5/C6 correspondence and cycle-specific splice locations remain explicitly pending.
for (let k = 1; k <= 3; k++) for (const [j, n] of ns.entries()) {
const name = `C${k + (j % 2 ? 0 : 3)}`;
const size = Number(name.slice(1)) <= 4 ? 'H25' : 'H29';
const d = NOMINAL_DIAMETER[size];
const a = settings.cover / 1000 + (k - 1) * d / 1000, b = L - a;
const z = top - 0.127 - (k - 1) * settings.mainLayerGap / 1000;
const hook = hookLength('main90', d) / 1000;
const total = (b - a + 2 * hook) * 1000;
const lap = spliceLength(d, fyOf(material, size), material.fck) / 1000;
if (model.topSplice.mode === 'apply' && total > 12000) {
const split = model.topSplice.at === 'center' ? L / 2 : b + hook + lap / 2 - 12;
add(name, size, [[a, z - hook, n], [a, z, n], [split + lap / 2, z, n]]);
add(name, size, [[split - lap / 2, z, n], [b, z, n], [b, z - hook, n]]);
} else add(name, size, [[a, z - hook, n], [a, z, n], [b, z, n], [b, z - hook, n]]);
}
for (const [j, n] of ns.entries()) {
const name = j % 2 ? 'C7' : 'C8', size = j % 2 ? 'H29' : 'H25';
const lap = spliceLength(NOMINAL_DIAMETER[size], fyOf(material, size), material.fck) / 2000;
const a = settings.cover / 1000, b = L - a;
const z = (x: number) => envelope.inset(x, (settings.cover + width.yE - 100) / 1000);
const path = (from: number, to: number): CopingPoint[] => [from, ...envelope.xs.filter(x => x > from && x < to), to].map(x => [x, z(x), n]);
add(name, size, [[a, top - 0.127, n], ...path(a, L / 2 + lap)]);
add(name, size, [...path(L / 2 - lap, b), [b, top - 0.127, n]]);
}
// Plan groups retain their functional IDs; the drawing also uses C13 for a shear loop.
const columns = model.columns.map(c => c.s).sort((a, b) => a - b);
const half = model.copingGeom.levelWidth / 2000;
const kMax = Math.ceil(half * 1000 / settings.spacingL);
const groups = columns.map(c => Array.from({ length: 2 * kMax + 1 }, (_, i) => c + (i - kMax) * settings.spacingL / 1000));
const positions: { name: string; x: number }[] = groups.flatMap(g => g.map(x => ({ name: 'C10', x })));
const gap = (settings.cover + settings.firstShearGap) / 1000;
for (let x = groups[0][0] - settings.spacingS / 1000; x >= gap - 1e-9; x -= settings.spacingS / 1000) positions.push({ name: 'C9', x });
for (let x = groups.at(-1)!.at(-1)! + settings.spacingS / 1000; x <= L - gap + 1e-9; x += settings.spacingS / 1000) positions.push({ name: 'C11', x });
for (let i = 1; i < groups.length; i++) {
const a = groups[i - 1].at(-1)!, b = groups[i][0], count = Math.floor((b - a) * 1000 / settings.spacingS);
for (let j = 1; j < count; j++) positions.push({ name: 'C9·C11 사이', x: a + (b - a) * j / count });
}
const ds = NOMINAL_DIAMETER.H22 / 1000, yTop = top - settings.cover / 1000;
const legs = settings.shearLegs, pitch = (B - 2 * settings.cover / 1000) / (legs - 1);
for (const { name, x } of positions) for (let i = 0; i < legs / 2; i++) {
const xu = x + i * 2 * ds, xc = xu + ds;
const a = -B / 2 + settings.cover / 1000 + i * pitch, b = a + legs / 2 * pitch;
const bottom = envelope.inset(xu, (settings.cover + 12) / 1000);
const capBottom = envelope.inset(xc, (settings.cover + 112) / 1000);
add(name, 'H22', [[xu, yTop, a], [xu, bottom, a], [xu, bottom, b], [xu, yTop, b]]);
add(name, 'H22', [[xc, capBottom, a], [xc, yTop, a], [xc, yTop, b], [xc, capBottom, b]]);
}
const levels = sideRowLevels(model.levelBottom, model.endBottom, top);
const sideRows: { z: number; intervals: [number, number][] }[] = [];
for (let z = levels.zBottom; z <= top - settings.sideEndGap / 1000 + 1e-9; z += settings.sideSpacing / 1000) {
const intervals: [number, number][] = [];
const points = [0.127, ...envelope.xs.filter(x => x > 0.127 && x < L - 0.127), L - 0.127];
for (let i = 1; i < points.length; i++) {
let a = points[i - 1], b = points[i];
const za = envelope.inset(a, 0.127), zb = envelope.inset(b, 0.127);
if (Math.min(za, zb) > z) continue;
if (za > z) a += (b - a) * (za - z) / (za - zb);
else if (zb > z) b = a + (b - a) * (z - za) / (zb - za);
const previous = intervals.at(-1);
if (previous && Math.abs(previous[1] - a) < 1e-8) previous[1] = b;
else intervals.push([a, b]);
}
sideRows.push({ z, intervals });
const name = z < levels.zBreak - 1e-9 ? 'C12' : 'C13', face = B / 2 - 0.127;
for (const [a, b] of intervals) {
add(name, 'H16', [[a, z, -face], [b, z, -face]]);
add(name, 'H16', [[a, z, face], [b, z, face]]);
// Short isolated intervals must not grow hooks through the adjacent haunch.
const leg = Math.min(SIDE_HOOK_LEG / 1000, (b - a) / 2), zh = z + NOMINAL_DIAMETER.H16 / 1000;
for (const [x, direction] of [[a, 1], [b, -1]]) add(name, 'H16', [[x + direction * leg, zh, -face], [x, zh, -face], [x, zh, face], [x + direction * leg, zh, face]]);
}
}
const hook = hookLength('main90', NOMINAL_DIAMETER.H16) / 1000;
const face = B / 2 - C14_INSET / 1000;
const addCross = (x: number, z: number) => {
if (z < envelope.inset(x, 0.1)) return;
const legsFit = z - hook >= envelope.inset(x, 0.1);
add('C14', 'H16', legsFit ? [[x, z - hook, -face], [x, z, -face], [x, z, face], [x, z - hook, face]] : [[x, z, -face], [x, z, face]]);
};
for (const k of [1, 2]) for (let x = L / 2 % (settings.c14UpperSpacing / 1000); x <= L - 0.6; x += settings.c14UpperSpacing / 1000) if (x >= 0.6) addCross(x, top - 0.127 - k * settings.mainLayerGap / 1000 + 0.024);
for (const [i, row] of sideRows.entries()) if (i % 2 === 0) for (let x = 0.6; x <= L - 0.6; x += settings.c14LowerSpacing / 1000) if (row.intervals.some(([a, b]) => x >= a && x <= b)) addCross(x, row.z + 0.02);
const stairs = stairBars(L * 1000, B * 1000, top, material, 2 * settings.mainSpacing);
for (const [name, list] of [['C15', stairs.c15], ['C16', stairs.c16]] as const) for (const s of list) {
const a = s.x0 / 1000, b = s.x1 / 1000, n = s.n / 1000, h = s.leg / 1000;
add(name, 'H19', [[a, s.z - h, n], [a, s.z, n], [b, s.z, n], ...(s.legs === 'both' ? [[b, s.z - h, n] as CopingPoint] : [])]);
}
return bars;
}
+10 -8
View File
@@ -60,25 +60,25 @@ export interface Quantity {
ton: number;
}
export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
export function buildQuantity(rows: RebarRow[], material: Material, incomplete: readonly string[] = []): Quantity {
const byGrade = new Map<number, Map<string, QuantityDiameter>>();
const couplers: { name: string; count: number }[] = [];
const missing: string[] = [];
const missing: string[] = [...incomplete];
for (const r of rows) {
if (r.name.includes('커플러')) {
couplers.push({ name: r.name, count: r.count });
continue;
}
if (!isBarSize(r.size)) continue;
if (!isBarSize(r.size) && r.diameterMm === undefined) continue;
// A row with several lengths carries pieces; a row with one length is one piece (count bars of that length).
const pieces = r.pieces ?? (r.length === null ? null : [{ length: r.length, count: r.count }]);
if (!pieces) {
missing.push(r.name);
continue;
}
const grade = fyOf(material, r.size);
const dMm = NOMINAL_DIAMETER[r.size];
const grade = r.gradeMpa ?? fyOf(material, r.size as BarSize);
const dMm = r.diameterMm ?? NOMINAL_DIAMETER[r.size as BarSize];
const kgPerM = unitWeight(dMm);
const pieceCount = pieces.reduce((s, p) => s + p.count, 0);
const totalM = pieces.reduce((s, p) => s + (p.count * p.length) / 1000, 0);
@@ -87,11 +87,12 @@ export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
length: pieces.length === 1 ? pieces[0].length : null, pieces, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape,
};
const diameters = byGrade.get(grade) ?? new Map<string, QuantityDiameter>();
const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
const sameDiameter = [...diameters.values()].find(d => Math.abs(d.dMm - dMm) < 1e-8);
const d = sameDiameter ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
d.lines.push(line);
d.totalM += totalM;
d.kg += line.kg;
diameters.set(r.size, d);
diameters.set(d.size, d);
byGrade.set(grade, diameters);
}
@@ -119,10 +120,11 @@ export function quantityRows(q: Quantity): (string | number)[][] {
}
out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]);
}
out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]);
out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]);
for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']);
for (const name of q.missing) out.push(['미계산 · 합계 미완료', name]);
return out;
}
+90 -15
View File
@@ -1,11 +1,14 @@
import type { PierModel } from '../geometry';
import { barLength, bearingBlockRebar, DEFAULT_BLOCK_REBAR, totals } from './bearing-block';
import { DEFAULT_COPING_REBAR } from './coping-settings';
import { copingDefaults } from './coping-settings';
import { DEFAULT_COLUMN_REBAR } from './column-settings';
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
import { sideRowLevels } from '../coping-geom';
import { CAST_PENDING } from './cast-in-place';
import { castRebarLayout } from './cast-layout';
import { multicolCopingBars } from './multicol-coping';
import { columnRebar, columnInputFor } from './column';
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common';
@@ -15,7 +18,7 @@ export type Basis = 'input' | 'default' | 'calc' | 'assumed';
/** One line of a 철근표. length is null where the document gives no length yet. */
export interface RebarRow {
member: 'bearing' | 'coping' | 'column' | 'footing';
member: 'bearing' | 'coping' | 'column' | 'footing' | 'pile';
name: string;
size: string;
count: number;
@@ -31,6 +34,9 @@ export interface RebarRow {
direction: string;
shape: string;
basis: Basis;
/** Explicit dedicated-section properties; absent for legacy rows. */
diameterMm?: number;
gradeMpa?: number;
}
export interface RebarWarning {
@@ -128,7 +134,7 @@ const MAIN_BARS = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8'];
*/
export function linkedSpacing(name: string, model: PierModel): Pick<RebarRow, 'spacing' | 'actual' | 'basis'> | undefined {
const c = model.copingRebar;
const d = DEFAULT_COPING_REBAR;
const d = copingDefaults(model.columns.length);
const b = model.blockRebar;
const bd = DEFAULT_BLOCK_REBAR;
const basis = (changed: boolean): Basis => (changed ? 'input' : 'default');
@@ -186,11 +192,12 @@ export function rebarSummary(
model: PierModel,
common: Material = DEFAULT_MATERIAL,
byMember: FckByMember = {},
): { rows: RebarRow[]; warnings: RebarWarning[] } {
): { rows: RebarRow[]; warnings: RebarWarning[]; incomplete: string[] } {
const rows: RawRow[] = [];
const warnings: RebarWarning[] = [];
const mCoping = materialFor(common, byMember, 'coping');
const mColumn = materialFor(common, byMember, 'column');
const cast = model.castInput ? castRebarLayout(model, mColumn) : undefined;
const mFooting = materialFor(common, byMember, 'footing');
// The I02 note stays while the common values are the assumed ones, unless every fck-bearing member has its own fck.
const ownFck = (['column', 'coping', 'footing'] as const).every((k) => byMember[k] !== undefined);
@@ -217,6 +224,9 @@ export function rebarSummary(
rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` });
// Coping (코핑철근.md §5–§8)
const colS = model.columns.map((c) => c.s * 1000);
const nCol = Math.max(1, colS.length);
if (nCol <= 1 || model.kink !== null) {
const width = widthLayout(B, model.copingRebar);
const perRow = width.count / 2; // cycle split, 12 per row
// CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept.
@@ -236,8 +246,6 @@ export function rebarSummary(
const bottom = bottomPiece(mCoping);
rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
const colS = model.columns.map((c) => c.s * 1000);
const nCol = Math.max(1, colS.length);
const geom = model.copingGeom;
const shear = nCol > 1 ? shearBarsMulti(L, colS, geom) : { ...shearBars(L, undefined, geom), between: [] as number[] };
const uRange = (xs: number[]) => {
@@ -262,7 +270,7 @@ export function rebarSummary(
rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, pieces: loopPieces(shear.between), note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` });
}
if (nCol > 1) {
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' });
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.`, target: 'coping' });
}
// Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2).
const levels = sideRowLevels(model.levelBottom, model.endBottom, model.copingRefTop);
@@ -282,21 +290,66 @@ export function rebarSummary(
const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing);
rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' });
rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` });
}
// Column (기둥철근.md · plan C4), P1 defaults
const col = columnRebar(columnInputFor(model), mColumn);
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: col.mainP1 })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: col.mainP2 })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
if (model.footings.length > 0) {
const columnRows: RawRow[] = [];
for (let columnIndex = 0; columnIndex < model.columns.length; columnIndex++) {
const first = rows.length;
const input = columnInputFor(model, undefined, columnIndex);
const col = columnRebar(input, mColumn);
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: input.nDiv })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: input.nDiv })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: `@${model.columnRebar.ctcCoupler}, P2는 +${model.columnRebar.dzCp2} mm 상향 엇갈림` });
const tieCouplerCount = col.p5Count + col.p4Count * 2;
rows.push({ member: 'column', name: '띠철근 커플러', size: 'D19용 (L100 Ø40)', count: tieCouplerCount, length: 100, note: `단부 P5 1개(${col.p5Count}) · 기초안 P4 2개(${col.p4Count * 2}) 폐합용` });
rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: col.p3Length, note: `일반 구간 띠철근 @${model.columnRebar.ctcP3}, 겹침 ${col.lapP3} (추천안, Q-C6)` });
rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: col.p5Length, note: `단부 띠철근 @${model.columnRebar.ctcP5}, 커플러 1개` });
rows.push({ member: 'column', name: 'P4', size: 'H19', count: col.p4Count, length: col.p4SemicircleLength * 2, note: '확대기초 안, 반원 2개 (길이는 반원 2개 합) · B11 간섭 무시' });
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: '크로스타이, 8단 × 4' });
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: `크로스타이, ${col.p6Levels.length}단 × 4` });
// Column bars and couplers repeat for each column (기둥철근 is per column; 다주형은 기둥마다).
for (const r of rows) if (r.member === 'column') r.count *= nCol;
columnRows.push(...rows.splice(first));
}
for (const r of columnRows) {
const existing = rows.find(v => v.member === 'column' && v.name === r.name);
if (!existing) rows.push({ ...r, pieces: r.pieces ? [...r.pieces] : undefined });
else {
if (existing.pieces || r.pieces || existing.length !== r.length) {
existing.pieces ??= [{ length: existing.length!, count: existing.count }];
existing.pieces.push(...(r.pieces ?? [{ length: r.length!, count: r.count }]));
existing.length = null;
}
existing.count += r.count;
existing.note = '기둥별 실제 배치·길이 합산 · 마디별 수량은 각 기둥의 원주 등분 수';
}
}
} else if (model.castInput) {
const layout = cast!;
const grouped = new Map<string, typeof layout.bars>();
for (const bar of layout.bars) {
const key = `${bar.name}|${bar.diameterMm}|${bar.gradeMpa}`;
const group = grouped.get(key) ?? [];
group.push(bar); grouped.set(key, group);
}
for (const group of grouped.values()) {
const first = group[0];
const byLength = new Map<number, { length: number; count: number }>();
for (const bar of group) {
const key = Math.round(bar.lengthMm * 1e6);
const piece = byLength.get(key) ?? { length: bar.lengthMm, count: 0 };
piece.count++; byLength.set(key, piece);
}
const pieces = [...byLength.values()];
rows.push({ member: first.member, name: first.name, size: first.kind === 'coupler' ? `커플러 Ø${first.diameterMm.toFixed(1)}` : `Ø${first.diameterMm}`,
count: group.length, length: pieces.length === 1 ? pieces[0].length : null, pieces,
diameterMm: first.diameterMm, gradeMpa: first.gradeMpa, note: first.note });
}
layout.missing.forEach(message => warnings.push({ code: 'RB-CIP01', message, target: 'column' }));
} else {
warnings.push({ code: 'RB-CIP01', message: CAST_PENDING.join(' · ') + ' — 전용 입력을 저장할 수 있으며 3D·철근표·재료표 계산은 상세 확정 후 제공합니다.', target: 'column' });
warnings.push({ code: 'RB-CIP02', message: '현장타설말뚝 철근 수량은 미계산입니다. 지원되는 다른 부재만 집계합니다.', target: 'pile' });
}
// Footing (확대기초철근 (1).md · plan C5). Only when the footing exists (not 현장타설말뚝).
if (model.footings.length > 0) {
// Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name;
@@ -327,10 +380,32 @@ export function rebarSummary(
} else {
warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' });
}
if (nCol > 1 && model.kink === null) {
const grouped = new Map<string, ReturnType<typeof multicolCopingBars>>();
for (const b of multicolCopingBars(model, mCoping)) {
const group = grouped.get(b.name) ?? [];
group.push(b); grouped.set(b.name, group);
}
for (const [name, bars] of grouped) rows.push({ member: 'coping', name, size: bars[0].size,
count: bars.length, length: null, pieces: bars.map(b => ({ length: b.length, count: 1 })),
note: '다주형 전용 삽도 규격 · 실제 콘크리트 하면에 따른 조각별 중심선 길이. 번호·cycle별 이음 상세 확인 필요' });
warnings.push({ code: 'RB-M02', target: 'coping', message: '다주형 전용 삽도 우선: C1~4 H25·C7 H29·C8 H25·전단 H22·수평/C14 H16. C5/C6 및 C11/C13 중복 번호·cycle별 이음 위치·분리 수평철근 갈고리는 상세 확인 필요. 해당 수량은 검토용입니다.' });
warnings.push({ code: 'RB-M01', target: 'coping', message: '다주형 C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.' });
warnings.push({ code: 'RB-M03', target: 'column', message: '다주형 단부는 삽도의 지름 이상·코핑 반높이 연장을 적용했습니다. P4/P5 번호는 기존 역할(단부 P5·기초내 P4)을 유지하며 도면 번호 대응 확인이 필요합니다.' });
}
// Table metadata per bar (구분·간격·실간격·위치·방향·형상·근거). The spacing rows take their values from the inputs (승인 2026-10-26).
const withMeta = rows.map((r) => {
if (r.diameterMm !== undefined) return { ...r, spacing: '입력 구간별', actual: '중심선 계산',
position: r.name.startsWith('기둥·말뚝') ? '기둥~말뚝 연속' : r.member === 'pile' ? '지중 말뚝' : '지상 기둥·코핑', direction: '배근 경로',
shape: r.name.includes('커플러') ? '커플러' : r.name.includes('나선') ? '나선' : r.name.includes('크로스타이') ? '양끝 갈고리' : /P[35]/.test(r.name) ? '원형' : '직선', basis: 'calc' as const };
const linked = linkedSpacing(r.name, model);
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}) };
const multi = nCol > 1 && r.member === 'coping';
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}), ...(nCol > 1 && r.member === 'column' && r.name === 'P5' ? { position: '상·하 단부 · 코핑 반높이까지 (번호 확인 중)' } : {}), ...(multi ? {
shape: '다주형 조각별 경로 (검토용)', position: '다주형 전용 삽도 · 실제 하면',
...(r.name === 'C9·C11 사이' ? { spacing: String(model.copingRebar.spacingS), actual: String(model.copingRebar.spacingS) } : {}) } : {}) };
});
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })) };
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })), incomplete: [
...(cast ? cast.missing : model.footings.length ? [] : CAST_PENDING),
...(nCol > 1 ? ['다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'] : []),
] };
}
+6
View File
@@ -1,6 +1,8 @@
import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings';
import type { ColumnRebarSettings } from './rebar/column-settings';
import type { FootingRebarSettings } from './rebar/footing-settings';
import type { CastInPlaceRebar } from './rebar/cast-in-place';
import type { CastInPlaceShapeSettings } from './cast-in-place-shape';
export type PierType = 'T형' | '2주형' | '3주형';
export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝';
@@ -88,7 +90,11 @@ export interface PierInput {
columnRebar?: Partial<ColumnRebarSettings>;
/** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Absent = the 확대기초철근 (1).md defaults. */
footingRebar?: Partial<FootingRebarSettings>;
/** 현장타설 전용 입력. 상세 규칙 확정 전에는 계산된 배근으로 취급하지 않는다. */
castInPlaceRebar?: CastInPlaceRebar;
strataRows?: StrataRow[];
/** 현장타설 형상·암반·희생강관 입력. */
castInPlaceShape?: CastInPlaceShapeSettings;
}
/** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */
+9
View File
@@ -3,6 +3,8 @@ import type { Issue, PierInput, PierType } from './types';
import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground';
import { integratedCheck } from './bearing-layout';
import { checkBlockRebar } from './rebar/block-check';
import { castSettingsIssues } from './rebar/cast-in-place';
import { castShape, castShapeIssues } from './cast-in-place-shape';
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
@@ -16,6 +18,13 @@ export const NOT_IMPLEMENTED_RULES = [
/** Input-level checks (spec §10 E01, E02). Returns false when the pier must not be built. */
export function validateInput(input: PierInput, issues: Issue[]): boolean {
let ok = true;
if (input.foundationType === '현장타설말뚝') {
for (const message of castShapeIssues(input.castInPlaceShape)) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message });
const shape = castShape(input);
if (shape.casingOn && shape.casingTopEl <= shape.casingBottomEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '희생강관 상단 EL은 하단 EL보다 높아야 합니다.' });
if (shape.weatheredRockEl < shape.bedrockEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '풍화암 EL이 기반암 EL보다 낮습니다. 암반 입력을 확인하세요.' });
for (const message of castSettingsIssues(input.castInPlaceRebar, input.pileLengthMm, input.columnDiameterMm ?? 2000)) issues.push({ code: 'CIP01', level: 'W', pier: input.name, message });
}
if (input.columnCount !== EXPECTED_COLUMNS[input.type]) {
issues.push({
code: 'E01',
+30
View File
@@ -0,0 +1,30 @@
import type { PierModel } from '../core/geometry';
import type { CastLayout } from '../core/rebar/cast-layout';
/** Front and section projections of the same centre lines used in the 3D scene. */
export function castRebarSvg(model: PierModel, layout: CastLayout): string {
const bars = layout.bars.filter(b => b.columnIndex === 0);
if (!bars.length) return '<p>입력 오류를 확인하세요. 생성된 기둥·말뚝 철근이 없습니다.</p>';
const pts = bars.flatMap(b => b.pts);
const bottom = pts.reduce((a, p) => Math.min(a, p[1]), Infinity);
const top = pts.reduce((a, p) => Math.max(a, p[1]), -Infinity);
const diameter = model.columnDiameterMm / 1000;
const scale = Math.min(180 / diameter, 480 / (top - bottom));
const x = (v: number) => 130 + (v - model.columns[0].s) * scale;
const y = (v: number) => 35 + (top - v) * scale;
const colour = (kind: string) => kind === 'main' ? '#0f5c66' : kind === 'coupler' ? '#0b3f46' : '#6b3fa0';
const front = bars.map(b => `<polyline points="${b.pts.map(p => `${x(p[0]).toFixed(2)},${y(p[1]).toFixed(2)}`).join(' ')}" fill="none" stroke="${colour(b.kind)}" stroke-width="${b.kind === 'coupler' ? 2 : 0.7}"/>`).join('');
const sectionHeight = model.groundEl + Math.min(1, (model.columns[0].top - model.groundEl) / 2);
const sectionScale = 140 / diameter;
const section = bars.filter(b => b.kind === 'main' && b.pts[0][1] <= sectionHeight && b.pts.at(-1)![1] >= sectionHeight).map(b => {
const p = b.pts[0];
return `<circle cx="${365 + (p[0] - model.columns[0].s) * sectionScale}" cy="${160 + p[2] * sectionScale}" r="${Math.max(1, b.diameterMm / 2000 * sectionScale)}" fill="#0f5c66"/>`;
}).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 470 560" role="img" aria-label="첫 기둥 및 현장타설말뚝 배근 정면·기둥 단면" style="width:100%;max-width:560px">
<text x="20" y="20" font-size="12">정면 · 실제 생성 경로</text>
<rect x="${x(model.columns[0].s - diameter / 2)}" y="${y(top)}" width="${diameter * scale}" height="${(top - bottom) * scale}" fill="none" stroke="#d9d6cc"/>
${front}<line x1="20" x2="245" y1="${y(model.groundEl)}" y2="${y(model.groundEl)}" stroke="#5b6878" stroke-dasharray="4 3"/>
<text x="20" y="${y(model.groundEl) - 5}" font-size="11">지반고 EL ${model.groundEl.toFixed(3)}</text>
<text x="285" y="40" font-size="12">기둥 주철근 단면</text><circle cx="365" cy="160" r="70" fill="none" stroke="#d9d6cc"/>${section}
<text x="20" y="548" font-size="11">단부·저부 P6 미확정 사항은 알림 참조 · 길이 단위 mm</text></svg>`;
}
+53 -5
View File
@@ -5,6 +5,8 @@ import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/c
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping';
import { sideRowLevels } from '../core/coping-geom';
import { columnRebar, columnInputFor } from '../core/rebar/column';
import { castRebarLayout } from '../core/rebar/cast-layout';
import { multicolCopingBars } from '../core/rebar/multicol-coping';
import { barDepths, barsAcross } from '../core/rebar/bearing-block';
import { blockFramePoint } from '../core/geometry';
import { INTEGRATED_BLOCK } from '../core/integrated-block';
@@ -65,6 +67,28 @@ const COLOR = {
const mm = (v: number) => v / 1000;
/** Arc-length interpolation avoids a linear scan per vertex on long spiral paths. */
class CastPolyline extends THREE.Curve<THREE.Vector3> {
private cumulative = [0];
constructor(private points: [number, number, number][]) {
super();
for (let i = 1; i < points.length; i++) this.cumulative.push(this.cumulative[i - 1] + Math.hypot(...points[i].map((v, j) => v - points[i - 1][j])));
}
getPoint(t: number, target = new THREE.Vector3()): THREE.Vector3 {
const distance = Math.max(0, Math.min(1, t)) * this.getLength();
let lo = 1, hi = this.points.length - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (this.cumulative[mid] < distance) lo = mid + 1; else hi = mid; }
const fraction = (distance - this.cumulative[lo - 1]) / (this.cumulative[lo] - this.cumulative[lo - 1] || 1);
const a = this.points[lo - 1], b = this.points[lo];
return target.set(a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction, a[2] + (b[2] - a[2]) * fraction);
}
getPointAt(t: number, target = new THREE.Vector3()): THREE.Vector3 { return this.getPoint(t, target); }
getTangentAt(t: number, target = new THREE.Vector3()): THREE.Vector3 {
return target.copy(this.getPoint(Math.min(1, t + 1e-5))).sub(this.getPoint(Math.max(0, t - 1e-5))).normalize();
}
getLength(): number { return this.cumulative.at(-1)!; }
}
export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene {
const mCoping = materialFor(common, byMember, 'coping');
const mColumn = materialFor(common, byMember, 'column');
@@ -140,7 +164,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
};
// Coping (straight coping only; a bent coping is not drawn in 3D yet).
if (straight) {
if (straight && m.columns.length > 1) {
for (const b of multicolCopingBars(m, mCoping)) {
if (/^C[1-8]$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping);
else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear);
}
}
if (straight && m.columns.length <= 1) {
const w = widthLayout(B * 1000, m.copingRebar);
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
const cover = 0.1;
@@ -279,9 +309,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
// Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column.
const colInput = columnInputFor(m);
const col = columnRebar(colInput, mColumn);
const zBot = col.zBot; // mm
if (m.footings.length > 0) {
const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => {
const N = 36;
for (let i = 0; i < N; i++) {
@@ -290,7 +318,10 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
addBar(name, k, [sc + r * Math.cos(a0), y, r * Math.sin(a0)], [sc + r * Math.cos(a1), y, r * Math.sin(a1)], d, COLOR.tie);
}
};
for (const c of m.columns) {
for (const [columnIndex, c] of m.columns.entries()) {
const colInput = columnInputFor(m, undefined, columnIndex);
const col = columnRebar(colInput, mColumn);
const zBot = col.zBot;
const sc = c.s;
const base = c.bottom;
const rt = mm(col.tieRadius);
@@ -376,6 +407,23 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
}
}
if (m.castInput) {
for (const bar of castRebarLayout(m, mColumn).bars) {
if (bar.pts.length === 2) {
addBar(bar.name, bar.k, bar.pts[0], bar.pts[1], bar.diameterMm, bar.kind === 'coupler' ? COLOR.couplerMain : COLOR.column);
} else {
records.push({ name: bar.name, k: bar.k, pts: bar.pts, dMm: bar.diameterMm });
const path = new CastPolyline(bar.pts);
const geometry = new THREE.TubeGeometry(path, Math.min(20000, bar.pts.length), mm(bar.diameterMm) / 2, 6, false);
geometry.applyMatrix4(Ms[bar.k]);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: rebarColor(COLOR.tie), roughness: 0.45, metalness: 0.1 }));
mesh.userData = { name: bar.name, roleColor: COLOR.tie, dMm: bar.diameterMm, member: 'column' };
group.add(mesh);
const list = byName.get(bar.name) ?? []; list.push(mesh); byName.set(bar.name, list);
}
}
}
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
for (const f of m.footings) {
const Lf = f.length;