Files
Aislo/common_util/common_util_culvert_sets.ts
T
eomsangdonandClaude Opus 5 8eb1189b4f fix(b05,b06,b08): 기슭막이 벽 높이를 관경 기준 한 값으로 · 폼은 기본값을 값으로 안 보냄
- 한 벽을 두고 값이 셋(등록부·표 H2.5 · 구조물도 H2.5 · 횡단도 관경 기준)이던 결함 — 근거 있는 관경 기준으로 맞춤
- 관 벽 높이 안 적으면 관경 + 여유 0.5 를 0.1m 로 올리고 근입 0.5 더함(Ø800 1.8 · Ø1000 2.0 · Ø1500 2.5)
- 파이썬(B06 엔진)·TS(세트 스펙·횡단 상수) 거울 시험 · 등록부 기본 2.5 걷고 empty_means
- 최소값이라 미확정·금액 밖은 그대로
- B05 벽 칸(관 유입·유출 · 독립 기슭막이)은 비워 두고 회색 제안값 · [제안값 넣기] 누른 때만 값 — 저장 한 번에 미확정이 확정으로 바뀌던 길을 막음
- 전체 시험: 받은 덤프 적재 커밋 몫 2개만 빨강(보고함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:10:47 +09:00

401 lines
17 KiB
TypeScript

/* =============================================================================
* common_util_culvert_sets.ts
* 배수관 세트(배관·기슭막이·보호공·세월교·BOX암거·물넘이포장) 제원 — **브라우저 몫**.
*
* ⚠ 파이썬 `B06_Section/B06_Section_Engine_Culvert.py` 와 **한 벌**이다(2026-09-06).
* 한쪽만 고치면 화면과 저장본이 갈린다. 거울 테스트: `tmp/tests/test_b06_culvert_sets_mirror.py`
*
* 왜 짝으로 두나(CLAUDE.md 5장 「계산 자리」) — 이 계산은 종횡단 **상세를 읽을 때마다**
* 서버에서 돈다. 매 요청 도는 자리라 Node 왕복(코리도·구조물 면적 방식)이 비싸다.
* 그래서 세 갈래 중 ①(파이썬·TS 짝 + 거울 테스트)을 골랐다.
*
* 값을 새로 만들지 않는다 — **정본(관 지점 옵션) + 레지스트리 기본값**만 조합한다.
* 상수 사본을 두면 B05 폼과 갈라지므로 기본값은 레지스트리에서만 꺼낸다.
* ========================================================================== */
/** 관 지점 정본 1건 — `pipe_points.json` 의 한 줄(브라우저는 API 로 같은 것을 받는다). */
export interface CulvertPipePoint {
chainage_m: number;
facility?: string;
options?: Record<string, unknown> | null;
}
/** 레지스트리 타입별 옵션 기본값 — `type_id → { 옵션키: 기본값 }`. */
export type CulvertRegistryDefaults = Record<string, Record<string, unknown>>;
/** 세트 제원 — 파이썬이 내는 dict 와 같은 모양이라 키를 그대로 쓴다. */
export type CulvertSetSpec = Record<string, unknown>;
/* ── 파이썬 짝과 같은 상수 (근거 주석은 파이썬 쪽에 있다) ───────────────── */
const CHAINAGE_TOLERANCE_M = 0.02;
const SECTION_KEYS: Record<string, string> = {
ford: "ford",
box: "box",
ford_pavement: "ford_pavement",
};
/** 폭 절반만큼 옆 측점에도 걸치는 종류 — 물넘이포장만. */
const SPAN_LINKED_TYPES = new Set(["ford_pavement"]);
export const MIN_PIPE_COVER_M = 0.5;
export const APRON_LENGTH_FACTOR = 2.0;
export const APRON_THICKNESS_M = 0.45;
export const REVET_FACE_SLOPE = 0.3;
export const INLET_STRUCTURE_BASIN = "집수정";
export const FORD_SLAB_THICKNESS_M = 0.3;
export const FORD_WALL_THICKNESS_M = 0.2;
export const FORD_DEFAULT_WIDTH_M = 10.0;
export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0;
export const BOX_COVER_M = 0.5;
/** 관 벽 최소 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조).
* 높이를 안 적은 관 벽은 이 값으로 섬(2026-09-14 브레인 판정 · 옛 등록부 기본 2.5 걷음). */
export const REVET_FREEBOARD_M = 0.5;
export const REVET_HEIGHT_STEP_M = 0.1;
export const REVET_EMBED_DEPTH_M = 0.5;
/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */
export function pipeWallMinHeightM(diameterM: number): number {
const raw = diameterM + REVET_FREEBOARD_M;
const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9);
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3);
}
/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */
const FACILITY_PIPE = "pipe";
const FACILITY_BOX = "box_culvert";
const FACILITY_FORD_PAVEMENT = "ford_pavement";
const FACILITY_FORD_BRIDGE = "ford_bridge";
const FACILITY_REVET = "revetment";
/** 숫자 옵션 하나를 정리한다. 문자열 저장분(관경 "1000")도 받는다 — 파이썬 `_number`. */
function num(value: unknown, fallback: number | null): number | null {
if (typeof value === "boolean") return fallback;
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
if (typeof value === "string") {
const parsed = Number(value.trim());
return value.trim() !== "" && Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
/** 파이썬 `round(x, n)` 자리에 쓰는 반올림. */
function round(value: number, digits: number): number {
return Number(value.toFixed(digits));
}
function text(value: unknown): string | null {
return value === null || value === undefined || value === "" ? null : String(value);
}
/**
* 유입("inlet")·유출("outlet") 한쪽의 부속 제원.
* 구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 화면은 라벨만 쓴다.
*/
function sideSpec(
options: Record<string, unknown>,
defaults: Record<string, unknown>,
side: string,
diameterM: number,
): CulvertSetSpec {
const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이";
const spec: CulvertSetSpec = { role: side, structure };
if (structure === INLET_STRUCTURE_BASIN) {
spec.basin_length_m = num(
options.inlet_basin_length_m,
num(defaults.inlet_basin_length_m, 2.0),
);
spec.basin_before_m = num(
options.inlet_basin_before_m,
num(defaults.inlet_basin_before_m, null),
);
spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null));
return spec;
}
const height = num(
options[`${side}_revet_height_m`],
num(defaults[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM)),
);
const length = num(
options[`${side}_revet_length_m`],
num(defaults[`${side}_revet_length_m`], null),
);
const form = text(options[`${side}_revet_form`]) ?? text(defaults[`${side}_revet_form`]);
const before = num(
options[`${side}_revet_before_m`],
num(defaults[`${side}_revet_before_m`], null),
);
const after = num(options[`${side}_revet_after_m`], num(defaults[`${side}_revet_after_m`], null));
spec.revet_form = form;
spec.revet_height_m = height;
spec.revet_length_m = length;
spec.revet_before_m = before;
spec.revet_after_m = after;
spec.face_slope = REVET_FACE_SLOPE;
// 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if (height !== null && height > 0) {
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
spec.apron_thickness_m = APRON_THICKNESS_M;
}
return spec;
}
/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공). */
function culvertSet(
options: Record<string, unknown>,
registry: CulvertRegistryDefaults,
): CulvertSetSpec {
const defaults = registry.pipe ?? {};
const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
const diameterM = pipeDiameterM(diameterMm);
return {
type: "pipe",
pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind),
diameter_m: diameterM,
min_cover_m: MIN_PIPE_COVER_M,
// 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로
// 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이
// 확인되면 그때 나눌 것.
foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation),
inlet: sideSpec(options, defaults, "inlet", diameterM),
outlet: sideSpec(options, defaults, "outlet", diameterM),
};
}
/**
* 독립 기슭막이 한쪽 벽 제원 — 배관 벽과 **같은 옵션 키**(`{role}_revet_*`)를 쓴다.
* 역할 키가 없으면 B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다.
*/
function revetSide(values: Record<string, unknown>, role: string): CulvertSetSpec {
const height = num(values[`${role}_revet_height_m`], num(values.height_m, null));
const form = text(values[`${role}_revet_form`]) ?? text(values.form);
const spec: CulvertSetSpec = {
role,
structure: "기슭막이",
revet_form: form,
revet_height_m: height,
revet_length_m: num(values[`${role}_revet_length_m`], num(values.length_m, null)),
revet_before_m: num(values[`${role}_revet_before_m`], num(values.before_m, null)),
revet_after_m: num(values[`${role}_revet_after_m`], num(values.after_m, null)),
face_slope: REVET_FACE_SLOPE,
};
if (height !== null && height > 0) {
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
spec.apron_thickness_m = APRON_THICKNESS_M;
}
return spec;
}
/** 독립 기슭막이 1개소 — 배관 세트 모양이되 **관을 숨긴다**(hidden_pipe). */
function revetSet(values: Record<string, unknown>): CulvertSetSpec {
return {
type: "pipe",
hidden_pipe: true,
side: String(values.side ?? "양쪽"),
tiers: Math.trunc(num(values.tiers, 1.0) || 1),
// 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. 비어 있으면 안 그린다.
foundation: text(values.foundation),
pipe_kind: null,
diameter_m: 0.3,
min_cover_m: 0.0,
inlet: revetSide(values, "inlet"),
outlet: revetSide(values, "outlet"),
};
}
/** 관경(㎜) → 관 지름(m). 파이썬 `_culvert_set`·`_ford_set` 과 같은 반올림. */
export function pipeDiameterM(diameterMm: number | null | undefined): number {
return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3);
}
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. */
export function boxSpanM(innerWidthM: number): number {
return innerWidthM + 2 * FORD_WALL_THICKNESS_M;
}
/** 날개벽이 만드는 바닥판 연장량 = 길이 × cos(벌어짐각). 안 세우면 0. */
export function wingSlabExtendM(
installed: boolean,
lengthM: number | null | undefined,
angleDeg: number | null | undefined,
): number {
if (!installed) return 0;
const extend = (lengthM ?? 0) * Math.cos(((angleDeg ?? 45) * Math.PI) / 180);
return round(Math.max(extend, 0.0), 3);
}
/**
* 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
* 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다.
*/
function wingSpec(
values: Record<string, unknown>,
defaults: Record<string, unknown>,
side: string,
): CulvertSetSpec {
const prefix = `wing_${side}`;
const install = values[prefix] ?? defaults[prefix];
const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0));
const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0));
const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0));
const installed = String(install ?? "").trim() !== "없음";
return {
installed,
height_m: height,
length_m: length,
angle_deg: angle,
slab_extend_m: wingSlabExtendM(installed, length, angle ?? 0),
};
}
/** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */
function fordSet(
values: Record<string, unknown>,
registry: CulvertRegistryDefaults,
): CulvertSetSpec {
const defaults = registry.ford_bridge ?? {};
const diameterMm = num(values.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
const count = num(values.pipe_count, num(defaults.pipe_count, null));
const depth = num(values.ford_height_m, null);
return {
type: "ford",
pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind),
diameter_m: pipeDiameterM(diameterMm),
pipe_count: count ? Math.max(Math.trunc(count), 1) : 1,
span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M,
// 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다.
overflow_depth_m: depth && depth > 0 ? depth : 0.0,
slab_thickness_m: FORD_SLAB_THICKNESS_M,
wall_thickness_m: FORD_WALL_THICKNESS_M,
min_cover_m: MIN_PIPE_COVER_M,
wing_in: wingSpec(values, defaults, "in"),
wing_out: wingSpec(values, defaults, "out"),
};
}
/** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */
function fordPavementSet(
values: Record<string, unknown>,
registry: CulvertRegistryDefaults,
): CulvertSetSpec {
const defaults = registry.ford_pavement ?? {};
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
const depth = num(values.ford_height_m, null);
return {
type: "ford_pavement",
span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M,
// 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
depth_m: depth && depth > 0 ? depth : null,
slope_pct: num(values.ford_slope_pct, null),
};
}
/** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */
function boxSet(
values: Record<string, unknown>,
registry: CulvertRegistryDefaults,
): CulvertSetSpec {
const defaults = registry.box_culvert ?? {};
const innerWidth = num(values.body_width_m, num(defaults.body_width_m, 2.0));
const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0));
const wall = FORD_WALL_THICKNESS_M;
const slab = FORD_SLAB_THICKNESS_M;
return {
type: "box",
inner_width_m: innerWidth || 2.0,
inner_height_m: innerHeight || 2.0,
wall_thickness_m: wall,
slab_thickness_m: slab,
top_thickness_m: slab,
cover_m: BOX_COVER_M,
span_m: boxSpanM(innerWidth || 2.0),
wing_in: wingSpec(values, defaults, "in"),
wing_out: wingSpec(values, defaults, "out"),
};
}
/** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */
export function buildCulvertSets(
points: readonly CulvertPipePoint[],
registry: CulvertRegistryDefaults,
): Map<number, CulvertSetSpec> {
const sets = new Map<number, CulvertSetSpec>();
for (const point of points) {
const chainage = num(point.chainage_m, null);
if (chainage === null) continue;
const values = (point.options ?? {}) as Record<string, unknown>;
const facility = point.facility || FACILITY_PIPE;
let spec: CulvertSetSpec;
if (facility === FACILITY_FORD_BRIDGE) spec = fordSet(values, registry);
else if (facility === FACILITY_BOX) spec = boxSet(values, registry);
else if (facility === FACILITY_FORD_PAVEMENT) spec = fordPavementSet(values, registry);
else if (facility === FACILITY_REVET) spec = revetSet(values);
else spec = culvertSet(values, registry);
sets.set(round(chainage, 2), spec);
}
return sets;
}
/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */
export function attachCulvertSets(
sections: Array<Record<string, unknown>>,
sets: ReadonlyMap<number, CulvertSetSpec>,
): number {
if (sets.size === 0) return 0;
let attached = 0;
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이
// 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다.
// ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.**
// ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`).
const owner = new Map<number, number>();
for (const [pipeChainage] of sets) {
let nearest: number | null = null;
for (const section of sections) {
const value = num(section.chainage_m, null);
if (value === null) continue;
if (nearest === null || Math.abs(value - pipeChainage) < Math.abs(nearest - pipeChainage)) {
nearest = value;
}
}
if (nearest !== null) owner.set(pipeChainage, nearest);
}
for (const section of sections) {
const chainage = num(section.chainage_m, null);
if (chainage === null) continue;
for (const [pipeChainage, spec] of sets) {
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
let reach = CHAINAGE_TOLERANCE_M;
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) {
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = {
...spec,
chainage_m: pipeChainage,
};
attached += 1;
break;
}
}
}
return attached;
}
/** 레지스트리 응답(타입 목록) → 기본값 표. 옵션 정의가 유일한 기본값 출처다. */
export function registryDefaults(
types: ReadonlyArray<{
type_id: string;
options: ReadonlyArray<{ key: string; default: unknown }>;
}>,
): CulvertRegistryDefaults {
const table: CulvertRegistryDefaults = {};
for (const type of types) {
const entry: Record<string, unknown> = {};
for (const option of type.options) entry[option.key] = option.default;
table[type.type_id] = entry;
}
return table;
}