From aad78ec942c83f857d31535f6ef963df88bde361 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 14:32:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=B0=B0=EC=88=98=EA=B4=80=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EC=A0=9C=EC=9B=90=20TS=20=EC=A7=9D=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=EC=97=90=EC=84=9C=EB=8F=84=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05·B06 계산을 서버·브라우저 양쪽에서 돌 수 있게 하는 작업의 1단계. 배수관 세트(배관·세월교·BOX암거·물넘이포장·독립 기슭막이) 제원이 서버에만 있어 브라우저는 스펙 칸을 하나씩 옮겨 적는 부분 사본으로 버티고 있었다. - common_util_culvert_sets.ts 신설 — B06_Section_Engine_Culvert.py 의 짝. 매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 택함(CLAUDE.md 5장 ①). - 거울 테스트 추가 — 다섯 시설을 태워 측점별 딕셔너리째 대조. - 흩어진 임시 산식 3곳(관경÷1000 · BOX 구체 길이 · 날개벽 바닥판 연장)을 이 한 벌로 바꿈. 검증: tsc --noEmit 통과, pytest 387 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Culvert.py | 4 + .../B06_Section_UI_Page_Ford_Controls.ts | 9 +- .../B06_Section_UI_Page_Pipe_Options.ts | 3 +- common_util/common_util_culvert_sets.ts | 357 ++++++++++++++++++ 4 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 common_util/common_util_culvert_sets.ts diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 43c39bca..b513afd5 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -4,6 +4,10 @@ B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고 **정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다. +⚠ TS 짝: `common_util/common_util_culvert_sets.ts` (2026-09-06). 한쪽만 고치면 화면과 +저장본이 갈린다 — 거울 테스트 `tmp/tests/test_b06_culvert_sets_mirror.py` 로 지킨다. +매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 골랐다(CLAUDE.md 5장 「계산 자리」 ①). + 근거(지식DB): · 관은 **지반선상 매설** — 뜨게 매설은 부적합 (교본 8장, 그림 8-3·8-9) · 관 지름 1,000㎜ 이상(현지여건 800㎜ 이상) — 별표2 Ⅰ.2.사.(2) diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index a787fd17..9b2e21cd 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -8,6 +8,7 @@ * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. * ========================================================================== */ +import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; @@ -150,7 +151,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { const spec = sectionAt(chainageM)?.ford; if (spec) { // 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다. - if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000; + if (patch.pipe_diameter_mm) spec.diameter_m = pipeDiameterM(patch.pipe_diameter_mm); if (patch.pipe_count) spec.pipe_count = patch.pipe_count; if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind; // 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리. @@ -168,9 +169,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { if (patch.height_m !== undefined) wing.height_m = patch.height_m; if (patch.length_m !== undefined) wing.length_m = patch.length_m; if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; - wing.slab_extend_m = wing.installed - ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) - : 0; + wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg); } deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); @@ -346,7 +345,7 @@ export function createBoxControls(deps: FordControlDeps): { // 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다. if (patch.body_width_m) { spec.inner_width_m = patch.body_width_m; - spec.span_m = patch.body_width_m + 2 * spec.wall_thickness_m; + spec.span_m = boxSpanM(patch.body_width_m); } if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; } diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 7d555281..a40eff9c 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -6,6 +6,7 @@ * 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다. * ========================================================================== */ +import { pipeDiameterM } from "@util/common_util_culvert_sets"; import { showToast } from "@ui/ui_template_elements"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; @@ -63,7 +64,7 @@ export function applyPipeOptionsToCache( }; put(culvert, "pipe_kind", patch.pipe_kind); const diameterMm = num("pipe_diameter_mm"); - if (diameterMm !== undefined) culvert.diameter_m = diameterMm / 1000; + if (diameterMm !== undefined) culvert.diameter_m = pipeDiameterM(diameterMm); // 유입·유출 기슭막이 제원과 집수정 구간값 — 폼 옵션 키를 스펙 자리로 옮긴다. for (const [side, prefix] of [ [culvert.inlet, "inlet"], diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts new file mode 100644 index 00000000..368d7af2 --- /dev/null +++ b/common_util/common_util_culvert_sets.ts @@ -0,0 +1,357 @@ +/* ============================================================================= + * 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, + 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), + 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; + 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) { + section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = spec; + 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; +}