/* ============================================================================= * 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 | null; } /** 레지스트리 타입별 옵션 기본값 — `type_id → { 옵션키: 기본값 }`. */ export type CulvertRegistryDefaults = Record>; /** 세트 제원 — 파이썬이 내는 dict 와 같은 모양이라 키를 그대로 쓴다. */ export type CulvertSetSpec = Record; /* ── 파이썬 짝과 같은 상수 (근거 주석은 파이썬 쪽에 있다) ───────────────── */ const CHAINAGE_TOLERANCE_M = 0.02; const SECTION_KEYS: Record = { 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; /** 시설 종류 — 파이썬 `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, defaults: Record, side: string, ): 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`], null), ); 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, registry: CulvertRegistryDefaults, ): CulvertSetSpec { const defaults = registry.pipe ?? {}; const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); return { type: "pipe", pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind), diameter_m: pipeDiameterM(diameterMm), min_cover_m: MIN_PIPE_COVER_M, // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 // 확인되면 그때 나눌 것. foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation), inlet: sideSpec(options, defaults, "inlet"), outlet: sideSpec(options, defaults, "outlet"), }; } /** * 독립 기슭막이 한쪽 벽 제원 — 배관 벽과 **같은 옵션 키**(`{role}_revet_*`)를 쓴다. * 역할 키가 없으면 B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다. */ function revetSide(values: Record, 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): 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, defaults: Record, 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, 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, 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, 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 { const sets = new Map(); for (const point of points) { const chainage = num(point.chainage_m, null); if (chainage === null) continue; const values = (point.options ?? {}) as Record; 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>, sets: ReadonlyMap, ): 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(); 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 = {}; for (const option of type.options) entry[option.key] = option.default; table[type.type_id] = entry; } return table; }