- 현장타설 기둥·말뚝 형상(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>
214 lines
11 KiB
TypeScript
214 lines
11 KiB
TypeScript
import type { FckByMember, Material } from './rebar/common';
|
|
import { checkStoredGroundModel, type GroundModelFile } from './ground-model';
|
|
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;
|
|
|
|
export interface ProjectInfo {
|
|
name: string;
|
|
bridge: string;
|
|
client: string;
|
|
author: string;
|
|
crs: string;
|
|
memo: string;
|
|
}
|
|
|
|
export interface ProjectPier {
|
|
id: string;
|
|
/** Generated input: the values the 3D model was built from. The model is not stored; it is regenerated on open (spec §8). */
|
|
input: PierInput;
|
|
/** Values as imported from the sheet (기본값). Missing in files saved before change tracking: input is used. */
|
|
defaults?: PierInput;
|
|
/** Values currently in the cards (현재 입력). Missing in older files: input is used. */
|
|
draft?: PierInput;
|
|
}
|
|
|
|
export interface ProjectFile {
|
|
schemaVersion: number;
|
|
project: ProjectInfo;
|
|
source: { fileName: string; sheets: string[] };
|
|
piers: ProjectPier[];
|
|
history: { version: string; note: string; at: string }[];
|
|
/** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */
|
|
common?: Material;
|
|
/** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */
|
|
fckByMember?: FckByMember;
|
|
/** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */
|
|
groundModel?: GroundModelFile;
|
|
/** 교대 (2026-11-03, AB): 교대 입력 기록. Absent in older files: no abutments. */
|
|
abutments?: AbutRecord[];
|
|
/** 교대 기본값 (가져온 그대로): 기본값 복원에 쓴다. Absent = 교대별 현재값이 기본값. */
|
|
abutDefaults?: AbutRecord[];
|
|
}
|
|
|
|
/** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */
|
|
export function serializeProject(p: ProjectFile): string {
|
|
return `${JSON.stringify(p, null, 2)}\n`;
|
|
}
|
|
|
|
/**
|
|
* Parses and checks a project file. Throws with a message that names the first problem, so a damaged file is
|
|
* refused instead of being half-loaded.
|
|
*/
|
|
export function parseProject(text: string): ProjectFile {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(text);
|
|
} catch {
|
|
throw new Error('프로젝트 파일이 JSON으로 읽히지 않습니다.');
|
|
}
|
|
const obj = raw as Partial<ProjectFile>;
|
|
if (!obj || typeof obj !== 'object') throw new Error('프로젝트 파일 형식이 아닙니다.');
|
|
if (obj.schemaVersion !== SCHEMA_VERSION) {
|
|
throw new Error(`지원하지 않는 파일 버전입니다 (schemaVersion ${String(obj.schemaVersion)}, 지원: ${SCHEMA_VERSION}).`);
|
|
}
|
|
if (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.');
|
|
if (obj.fckByMember !== undefined) {
|
|
for (const [member, value] of Object.entries(obj.fckByMember)) {
|
|
if (!['column', 'coping', 'footing', 'abutFooting', 'abutBody'].includes(member) || typeof value !== 'number' || !(value > 0)) {
|
|
throw new Error(`부재별 fck 값이 맞지 않습니다 (${member}: ${String(value)}).`);
|
|
}
|
|
}
|
|
}
|
|
if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel);
|
|
const pierNames = obj.piers.map((p) => p?.input?.name).filter((name): name is string => typeof name === 'string');
|
|
if (obj.abutments !== undefined) checkStoredAbutments(obj.abutments, pierNames);
|
|
if (obj.abutDefaults !== undefined) checkStoredAbutments(obj.abutDefaults, pierNames);
|
|
obj.piers.forEach((p, i) => {
|
|
if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`);
|
|
const input = p.input;
|
|
const rows = input.strataRows;
|
|
if (rows !== undefined) {
|
|
const ok = Array.isArray(rows) && rows.every((r) => typeof r?.name === 'string' && typeof r?.bottomEl === 'number' && Number.isFinite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string'));
|
|
if (!ok) throw new Error(`교각 ${input.name}: 지층 행 형식이 맞지 않습니다.`);
|
|
}
|
|
if (typeof input.name !== 'string' || !Array.isArray(input.ips) || !Array.isArray(input.bearings)) {
|
|
throw new Error(`교각 ${i + 1}(${String(input.name)}): 입력 형식이 맞지 않습니다.`);
|
|
}
|
|
for (const extra of [p.defaults, p.draft]) {
|
|
if (extra !== undefined && (typeof extra !== 'object' || extra === null || typeof extra.name !== 'string')) {
|
|
throw new Error(`교각 ${i + 1}(${input.name}): defaults 또는 draft 형식이 맞지 않습니다.`);
|
|
}
|
|
}
|
|
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}: 받침 방위각 좌표계 표기가 맞지 않습니다.`);
|
|
}
|
|
if (values && !Array.isArray(values.bearings)) {
|
|
throw new Error(`교각 ${input.name}: 받침 목록이 없습니다.`);
|
|
}
|
|
}
|
|
p.input = normalizeBearingAzimuths(p.input);
|
|
if (p.defaults) p.defaults = normalizeBearingAzimuths(p.defaults);
|
|
if (p.draft) p.draft = normalizeBearingAzimuths(p.draft);
|
|
});
|
|
return obj as ProjectFile;
|
|
}
|
|
|
|
const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v);
|
|
const isPoint = (v: unknown) => !!v && typeof v === 'object' && finite((v as { e?: unknown }).e) && finite((v as { n?: unknown }).n);
|
|
|
|
/** 교대 기록 check (AB): refuses a damaged abutment record with a message that names it. */
|
|
export function checkStoredAbutments(list: unknown, reservedNames: readonly string[] = []): void {
|
|
if (!Array.isArray(list)) throw new Error('교대 목록 형식이 맞지 않습니다.');
|
|
const names = new Set(reservedNames.map((name) => name.trim()));
|
|
list.forEach((a: Partial<AbutRecord>, i) => {
|
|
const who = `교대 ${i + 1}(${String(a?.name)})`;
|
|
const nameError = abutNameError(a?.name);
|
|
if (nameError) throw new Error(`${who}: ${nameError}`);
|
|
const name = a.name!.trim();
|
|
if (names.has(name)) throw new Error(`${who}: 구조물명 "${name}"이 중복되었습니다.`);
|
|
names.add(name);
|
|
a.name = name;
|
|
if (a.type !== 'A1' && a.type !== 'A2') throw new Error(`${who}: 형식은 A1 또는 A2여야 합니다.`);
|
|
for (const key of ['seatEl', 'foundationBottomEl', 'groundEl', 'pileLengthMm', 'skewDeg', 'slopeI'] as const) {
|
|
if (!finite(a[key])) throw new Error(`${who}: ${key} 값이 맞지 않습니다.`);
|
|
}
|
|
for (const key of ['ip1', 'ip2', 'ip3'] as const) {
|
|
if (!isPoint(a[key])) throw new Error(`${who}: ${key} 좌표가 맞지 않습니다.`);
|
|
}
|
|
if (!Array.isArray(a.bearings) || a.bearings.length < 2) throw new Error(`${who}: 받침이 2개 이상 필요합니다.`);
|
|
if (a.shape !== undefined) {
|
|
for (const [key, v] of Object.entries(a.shape)) {
|
|
if (!finite(v)) throw new Error(`${who}: 형상 값 ${key}이(가) 숫자가 아닙니다.`);
|
|
}
|
|
}
|
|
if (a.bearingRule !== undefined) {
|
|
for (const [key, v] of Object.entries(a.bearingRule)) {
|
|
// 'kind' (받침 종류) was stored by an earlier version and is ignored now (2026-11-03).
|
|
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.slab !== undefined) {
|
|
for (const [key, v] of Object.entries(a.slab)) {
|
|
// 'slope' was stored by an earlier version (2026-11-04 오전) and is ignored now: the form sets the slope.
|
|
const x: unknown = v;
|
|
const ok = key === 'form' ? x === 'buried' || x === 'exposed' : key === 'slope' ? x === 'down4' || x === 'grade' : finite(x);
|
|
if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
}
|
|
if (a.ground !== undefined) {
|
|
const g = a.ground as Record<string, unknown>;
|
|
for (const [key, v] of Object.entries(g)) {
|
|
const ok =
|
|
key === 'groundPosition' ? v === '육상' || v === '하상' || v === '해상'
|
|
: key === 'groundWaterOn' ? typeof v === 'boolean'
|
|
: key === 'strataRows'
|
|
? Array.isArray(v) && v.every((r) => r && typeof r.name === 'string' && finite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string'))
|
|
: finite(v);
|
|
if (!ok) throw new Error(`${who}: 지반 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
}
|
|
if (a.rebar !== undefined) {
|
|
for (const [key, v] of Object.entries(a.rebar.footing ?? {})) {
|
|
const x: unknown = v;
|
|
const ok = key === 'dir' ? x === 'skew' || x === 'perp' : finite(x);
|
|
if (!ok) throw new Error(`${who}: 저판 배근 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
for (const [key, v] of Object.entries(a.rebar.wall ?? {})) {
|
|
if (!finite(v)) throw new Error(`${who}: 벽체 배근 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
for (const [key, v] of Object.entries(a.rebar.parapet ?? {})) {
|
|
if (!finite(v)) throw new Error(`${who}: 흉벽 배근 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
for (const [key, v] of Object.entries(a.rebar.wing ?? {})) {
|
|
if (!finite(v)) throw new Error(`${who}: 날개벽 배근 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
for (const [key, v] of Object.entries(a.rebar.slab ?? {})) {
|
|
if (!finite(v)) throw new Error(`${who}: 접속슬래브 배근 값 ${key}이(가) 맞지 않습니다.`);
|
|
}
|
|
for (const [key, v] of Object.entries(a.rebar.block ?? {})) {
|
|
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}: 날개벽 형식이 맞지 않습니다.`);
|
|
}
|
|
for (const b of a.bearings) {
|
|
if (!b || !finite(b.no) || !isPoint(b.pos) || !finite(b.z) || (b.seatEl !== undefined && !finite(b.seatEl)) || (b.azimuthDeg !== undefined && !finite(b.azimuthDeg))) {
|
|
throw new Error(`${who}: 받침 ${String(b?.no)}의 값이 맞지 않습니다.`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|