From e3cb11185cc53625e242a54be99798aa01620c74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:53:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B06):=20=EA=B4=80=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EA=B3=B5=20=EC=9C=A0=EC=9E=85=20=C2=B7=20=EC=9C=A0=EC=B6=9C=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=EC=9E=85=20=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EA=B8=B0=EB=B3=B8=20=3D=20=EA=B4=80?= =?UTF-8?q?=EA=B2=BD=20+=200.5=20=C2=B7=20=EA=B7=B8=EB=A6=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B0=92=20=EC=9D=BD=EC=9D=8C=20=C2=B7=20=EC=98=9B?= =?UTF-8?q?=201.5(=EA=B4=80=EA=B2=BD=20>=201.0)=20=EC=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EA=B4=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EB=94=B0=EB=9D=BC=EA=B0=90(139-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_structure_form.ts | 4 ++ .../B04_PreProcess_Router_Basins.py | 19 +++++ .../B05_Profile_UI_Structures_Fields.ts | 6 ++ B06_Section/B06_Section_Engine_Culvert.py | 41 +++++++---- .../B06_Section_UI_Cross_Culvert_Const.ts | 20 +++--- B08_Quantity/B08_Quantity_Router_Summary.py | 14 +++- common_util/common_util_culvert_sets.ts | 72 +++++++++++++++++-- resources/master_template/구조물정의.json | 16 ++--- .../tester/test_139_1_pipe_height_follow.py | 71 ++++++++++++++++++ resources/tester/test_98_b08_pipe_summary.py | 8 +-- .../tester/test_b05_new_structure_defaults.py | 7 +- .../test_b05_structures_registry_policy.py | 6 +- resources/tester/test_b06_culvert_set.py | 29 +++++++- resources/tester/test_b06_height_unlock.py | 6 +- .../test_b06_pipe_wall_height_mirror.py | 27 +++++-- 15 files changed, 287 insertions(+), 59 deletions(-) create mode 100644 resources/tester/test_139_1_pipe_height_follow.py diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index ef81cf25c..9e6ef81bf 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -39,6 +39,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; +import { followPipeHeights } from "../../common_util/common_util_culvert_sets"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { @@ -169,6 +170,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { raw: string, view: Instance, ): void => { + if (spec?.typeId === "pipe" && c.id === "pipe" && item.id === "diameter") + followPipeHeights(state, raw, values[item.id] ?? ""); // 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13) const parent = item.by?.item; if (parent && view[parent] !== values[parent]) { @@ -342,6 +345,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { names = nextNames; groupNames = nextGroups; state = readState(next, options); + if (next.typeId === "pipe") followPipeHeights(state, state.pipe?.[0]?.diameter ?? ""); recent.clear(); paint(); }, diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index e905d5e03..e299c4754 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m from common_util.common_util_drainage_context import DrainageContext, load_drainage_context from common_util.common_util_drainage_detail import DrainageDetail, build_detail from common_util.common_util_drainage_pipes import ( @@ -89,6 +90,17 @@ def _build( return detail, carried +# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) +PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m") + + +def _float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + def _apply_new_defaults( points: list[PipePoint], flows: dict[float, float | None] | None = None ) -> None: @@ -111,6 +123,13 @@ def _apply_new_defaults( # 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`) if options.get("inlet_type") == "집수정": options.setdefault("inlet_structure", "L") + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`) + keys = {field.key for field in definition.options} + diameter_m = _float(options.get("pipe_diameter_mm")) / 1000 + if diameter_m > 0: + for key in PIPE_HEIGHT_KEYS: + if key in keys: + options.setdefault(key, pipe_wall_min_height_m(diameter_m)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) height = ford_height_m(flow, float(options.get("ford_width_m") or 0)) diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 262ea9815..95181eb53 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -9,6 +9,7 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; +import { fillPipeHeights } from "../common_util/common_util_culvert_sets"; export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); @@ -110,6 +111,11 @@ export function optionDefaults( ); // 배관 집수정 모양도 실제 값으로(54-39 · 54-36 기본 · 자동 배치 짝 `_apply_new_defaults`) if (values.inlet_type === "집수정") values.inlet_structure ??= DEFAULT_BASIN_SHAPE; + // 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1) + fillPipeHeights( + values, + options.map((option) => option.key), + ); return values; } diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ee225258d..c58c4fe59 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -71,26 +71,42 @@ CHUTE_THICKNESS_CM = 30.0 # 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다. REVET_FACE_SLOPE = 0.3 -# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts` -# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`). +# ── 관 벽 기본 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts` +# (`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`revetTargetHeight`·`pipeWallMinPureHeight`). # 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정 # 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험 # `test_b06_pipe_wall_height_mirror`. -REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값) REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금 REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이 +# 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라 +# 읽을 때 관경 기준 값으로 봄(139-1). +OLD_PIPE_WALL_DEFAULT_M = 1.5 def pipe_wall_min_height_m(diameter_m: float) -> float: - """관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함. + """관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1). - ⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖). + 관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. """ - raw = float(diameter_m) + REVET_FREEBOARD_M + raw = float(diameter_m) steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3) +def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None: + """관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1). + + 빈 칸 = None(부르는 쪽이 빈 칸 규칙). ⚠ TS 짝 `common_util_culvert_sets.pipeWallHeightM`. + """ + if ( + stored is not None + and abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 + and float(diameter_m) > 1.0 + 1e-9 + ): + return pipe_wall_min_height_m(diameter_m) + return stored + + # (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지 # 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다. @@ -188,16 +204,17 @@ def _side_spec( None, ) # 집수정 높이(좌측 폼 · 비면 정의 기본값) — 횡단도 집수정 벽 높이. - spec["basin_height_m"] = _number(options.get("inlet_basin_height_m"), None) + spec["basin_height_m"] = pipe_wall_height_m( + _number(options.get("inlet_basin_height_m"), None), diameter_m + ) # 집수정 모양(54-39) — 빈 값 = 안 고름 spec["basin_form"] = str(options.get("inlet_structure") or "") or None return spec - # 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음). - height = _number( - options.get(f"{side}_revet_height_m"), - pipe_wall_min_height_m(diameter_m), - ) + # 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값. + height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m) + if height is None: + height = pipe_wall_min_height_m(diameter_m) length = _number( options.get(f"{side}_revet_length_m"), None, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 1718c17c8..673bfeaaf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -24,23 +24,22 @@ export const REVET_LEAN_RATIO = 0.3; export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2; /** - * 기슭막이 여유고(m) — 관 상단에서 벽 상단까지의 간격. 유입·유출 **공통**이다. + * 집수정 상부 여유(m) — 관 상단에서 집수정 벽 상단까지의 간격. 관 벽(유입·유출)은 + * 여유 없이 벽 윗면 = 관 윗면(139-1 · `revetTargetHeight`). * * ⚠ 확정값 아님(2026-08-21 사용자 지정). 지식DB에 임도 배수관 기슭막이 여유고 * 규정이 없다(`통수단면.md` §여유고 — "임도·KDS 원문에 수치 없음"). 관 상단 최소 * 토피 0.5m와 같은 값을 잠정 채택했고, 추후 **사용자 입력값**으로 교체 예정이다. - * - * 이 값 때문에 벽 높이는 좌·우가 같은 규칙으로 정해진다 — 관이 경사져 벽 밑(invert) - * 이 서로 달라도 관 상단 위 간격은 항상 같다. */ export const REVET_FREEBOARD_M = 0.5; /** 기슭막이 높이 눈금(m) — 계산 높이를 이 단위로 올린다(2026-08-21 사용자 확정). */ export const REVET_HEIGHT_STEP_M = 0.1; -/** 관경에 여유고를 더해 눈금(0.1m)으로 올린 기슭막이 높이(m). */ +/** 관 벽 계산용 높이(m · invert~상단) — 관경을 눈금(0.1m)으로 올림 → 벽 윗면 = 관 윗면(139-1). + * ⚠ 파이썬 짝 `pipe_wall_min_height_m` · 세트 짝 `pipeWallMinHeightM`(거울 시험). */ export function revetTargetHeight(diameterM: number): number { - const raw = diameterM + REVET_FREEBOARD_M; + const raw = diameterM; return Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; } @@ -139,7 +138,7 @@ export function revetWallSpec( // 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름). const preferred = hiddenPipe ? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M) - : pipeWallMinPureHeight(diameterM); + : (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1) const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M); return { form, @@ -154,10 +153,9 @@ export function revetWallSpec( export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); /** - * 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이(2026-08-23 - * 사용자 확정: 배관 기준 아래 0.5·위 0.5 여유). 값은 근입 깊이와 같은 0.5라 - * 형상은 종전과 동일하고, **순수 높이 하한 = 관경 + 위 0.5 + 아래 0.5**가 된다. - * 종전 고정 하한·기본 2.0m(관경 무관)는 폐기. + * 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5. + * **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · + * 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값. */ export function pipeWallMinPureHeight(diameterM: number): number { return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M; diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 95e78bd6a..3fd848719 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -36,7 +36,7 @@ from uuid import UUID from fastapi import APIRouter -from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m +from B06_Section.B06_Section_Engine_Culvert import pipe_wall_height_m, pipe_wall_min_height_m from B06_Section.B06_Section_Repository import ( get_confirmed_route_context, get_cross_section_designs, @@ -558,7 +558,17 @@ def pipe_summary( sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL] sources = [(side, links.get(LINKS[side])) for side in sides] - _revets(sheet, at, options, links, sources, pipe_wall_min_height_m(diameter / 1000), names) + # 옛 기본 1.5 · 관경 > 1.0 = 관경 기준 값(139-1 · 그림과 한 값) + moved = { + f"{side}_revet_height_m": pipe_wall_height_m( + _num(options.get(f"{side}_revet_height_m")), diameter / 1000 + ) + for side in sides + } + wall_options = {**options, **moved} + _revets( + sheet, at, wall_options, links, sources, pipe_wall_min_height_m(diameter / 1000), names + ) _chutes(sheet, at, options, links, names) out = sheet.result() diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 8c7fef06b..7fb8a7e27 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -46,19 +46,75 @@ export const FORD_DEFAULT_WIDTH_M = 10.0; export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 10.0; export const FORD_PAVEMENT_DEFAULT_SLOPE_DEG = 3.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; +/** 관 벽 기본 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조). + * 높이를 안 적은 관 벽은 이 값으로 섬(139-1 · 관경 + 0.5 = 벽 윗면 = 관 윗면). */ export const REVET_HEIGHT_STEP_M = 0.1; export const REVET_EMBED_DEPTH_M = 0.5; +export const OLD_PIPE_WALL_DEFAULT_M = 1.5; -/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */ +/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. */ export function pipeWallMinHeightM(diameterM: number): number { - const raw = diameterM + REVET_FREEBOARD_M; + const raw = diameterM; const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9); return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3); } +/** 관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1) · + * 빈 칸 = null. 파이썬 `pipe_wall_height_m`. */ +export function pipeWallHeightM(stored: number | null, diameterM: number): number | null { + if ( + stored !== null && + Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 && + diameterM > 1.0 + 1e-9 + ) + return pipeWallMinHeightM(diameterM); + return stored; +} + +/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1 · 서버 짝 `PIPE_HEIGHT_KEYS`). */ +export const PIPE_HEIGHT_KEYS = [ + "inlet_revet_height_m", + "outlet_revet_height_m", + "inlet_basin_height_m", +]; + +/** 새 관 옵션 기본값에 높이 칸을 채움 — 그 칸이 정의에 있고 비었을 때만(B05 `optionDefaults`). */ +export function fillPipeHeights( + values: Record, + keys: ReadonlyArray, +): void { + const diameterM = Number(values.pipe_diameter_mm) / 1000; + if (!(diameterM > 0)) return; + for (const key of PIPE_HEIGHT_KEYS) + if (keys.includes(key)) values[key] ??= pipeWallMinHeightM(diameterM); +} + +/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1). + * `previousMm` 없음 = 읽을 때(옛 1.5 · 관경 > 1.0 옮김) · 있음 = 관경을 바꿈(옛 관경 규칙 값 · 옛 1.5 + * 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로). */ +export function followPipeHeights( + state: Record> | undefined>, + diameterMm: string, + previousMm?: string, +): void { + const d = Number(diameterMm) / 1000; + if (!(d > 0)) return; + const prev = previousMm ? pipeWallMinHeightM(Number(previousMm) / 1000) : null; + for (const id of ["inlet", "outlet"]) { + const values = state[id]?.[0]; + const raw = values?.height ?? ""; + if (!values || raw === "") continue; + const now = Number(raw); + const moved = + previousMm === undefined + ? pipeWallHeightM(now, d) + : [prev, OLD_PIPE_WALL_DEFAULT_M].some((v) => v !== null && Math.abs(now - v) < 1e-9) + ? pipeWallMinHeightM(d) + : now; + if (moved !== null && moved !== now) values.height = String(moved); + } +} + /** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */ const FACILITY_PIPE = "pipe"; const FACILITY_BOX = "box_culvert"; @@ -107,12 +163,14 @@ function sideSpec( ); spec.basin_before_m = num(options.inlet_basin_before_m, null); spec.basin_after_m = num(options.inlet_basin_after_m, null); - spec.basin_height_m = num(options.inlet_basin_height_m, null); + spec.basin_height_m = pipeWallHeightM(num(options.inlet_basin_height_m, null), diameterM); spec.basin_form = text(options.inlet_structure); // 집수정 모양(54-39) — 빈 값 = 안 고름 return spec; } - const height = num(options[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM)); + const height = + pipeWallHeightM(num(options[`${side}_revet_height_m`], null), diameterM) ?? + pipeWallMinHeightM(diameterM); const length = num(options[`${side}_revet_length_m`], null); const form = text(options[`${side}_revet_form`]); const before = num(options[`${side}_revet_before_m`], null); diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 2b5b5bbf4..fd51c39d4 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -153,10 +153,10 @@ "label": "집수정 높이", "input": "number", "unit": "m", - "default": 1.5, + "default": null, "required": false, "phase": "detail", - "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" }, { "key": "inlet_revet_form", @@ -205,11 +205,11 @@ "label": "유입 흙막이 높이", "input": "number", "unit": "m", - "default": 1.5, + "default": null, "required": false, "phase": "detail", - "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", - "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + "empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)", + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" }, { "key": "outlet_type", @@ -269,11 +269,11 @@ "label": "유출 흙막이 높이", "input": "number", "unit": "m", - "default": 1.5, + "default": null, "required": false, "phase": "detail", - "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", - "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + "empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)", + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" }, { "key": "revet_foundation", diff --git a/resources/tester/test_139_1_pipe_height_follow.py b/resources/tester/test_139_1_pipe_height_follow.py new file mode 100644 index 000000000..978ac0f69 --- /dev/null +++ b/resources/tester/test_139_1_pipe_height_follow.py @@ -0,0 +1,71 @@ +"""139-1 관 보호공 높이 = 관경 + 0.5 — 화면 쪽(새 관 기본값 · 폼 읽기 옮김 · 관경 따라감). + +- B05 `optionDefaults` → `fillPipeHeights`: 새 관 높이 칸 셋(유입 · 유출 흙막이 · 유입 집수정) = 관경 + 0.5. +- 폼 `followPipeHeights`(둘 다 `common_util_culvert_sets.ts`): 읽을 때 옛 1.5 · 관경 > 1.0 → 관경 + 0.5 · 관경을 바꾸면 옛 규칙 값 · + 옛 1.5 칸만 따라감(손 값 · 빈 칸 그대로). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { fillPipeHeights, followPipeHeights } from "./common_util_culvert_sets.js"; +const keys = ["pipe_diameter_mm", "inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"]; +const fresh = { pipe_diameter_mm: 1200 }; fillPipeHeights(fresh, keys); +const noPipe = {}; fillPipeHeights(noPipe, keys); +const st = (d, i, o) => ({ pipe: [{ diameter: d }], inlet: [{ height: i }], outlet: [{ height: o }] }); +const read = st("1200", "1.5", "1.6"); followPipeHeights(read, "1200"); +const small = st("800", "1.5", ""); followPipeHeights(small, "800"); +const change = st("1200", "1.7", "2.2"); followPipeHeights(change, "1500", "1200"); +const old = st("1000", "1.5", ""); followPipeHeights(old, "800", "1000"); +writeFileSync(process.argv[2], JSON.stringify({ fresh, noPipe, read, small, change, old })); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_화면_높이_규칙(tmp_path: Path) -> None: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "common_util" / "common_util_culvert_sets.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--skipLibCheck", + "--ignoreConfig", + ], + cwd=str(ROOT), + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + run = subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], capture_output=True, text=True + ) + assert run.returncode == 0, run.stderr[-2000:] + got = json.loads(result.read_text(encoding="utf-8")) + + for key in ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"): + assert got["fresh"][key] == pytest.approx(1.7), key # Φ1200 → 1.7 + assert "inlet_revet_height_m" not in got["noPipe"] # 관경 없음 = 안 채움 + assert got["read"]["inlet"][0]["height"] == "1.7" # 옛 1.5 · Φ1200 → 1.7 + assert got["read"]["outlet"][0]["height"] == "1.6" # 손 값 그대로 + assert got["small"]["inlet"][0]["height"] == "1.5" # Φ800 의 1.5 는 그대로 + assert got["change"]["inlet"][0]["height"] == "2" # 옛 규칙 값(1.7) → 새 규칙(2.0) + assert got["change"]["outlet"][0]["height"] == "2.2" # 손 값 그대로 + assert got["old"]["inlet"][0]["height"] == "1.3" # 옛 1.5 → Φ800 규칙 + assert got["old"]["outlet"][0]["height"] == "" # 빈 칸 그대로 diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index feaa73c5a..c0bdd57d1 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -141,10 +141,10 @@ def test_흙막이_형태_높이마다_길이(root): # 집수정 유입은 흙막이로 안 셈 · 유출만 assert _cell(out, 1567, "pipe:revet|돌쌓기(찰)|2.0") is None assert _cell(out, 1567, "pipe:revet|돌쌓기(메)|2.0") == 10 - # 빈 높이 = 횡단도 관경 최소(Φ800 → 1.8) · 정의 기본 1.5 아님 · 빈 형태 = 미입력 - assert _cell(out, 1790, "pipe:revet|-|1.8") == 10 - head = next(c for c in out["columns"] if c["id"] == "pipe:revet|-|1.8") - assert head["group"][1:] == ["미입력", "H=1.8"] + # 빈 높이 = 횡단도 관경 기준(Φ800 → 1.3 · 139-1) · 빈 형태 = 미입력 + assert _cell(out, 1790, "pipe:revet|-|1.3") == 10 + head = next(c for c in out["columns"] if c["id"] == "pipe:revet|-|1.3") + assert head["group"][1:] == ["미입력", "H=1.3"] def test_돌붙임은_형식_면적이_다_있을_때만(root): diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index 3ec8dd184..b85ce87ae 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -34,7 +34,9 @@ def test_새_관만_기본값_빈_키에만(): fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800}) basins._apply_new_defaults([fresh]) # 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39) - expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L"} + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) + heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3) + expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights} assert fresh.options == expected assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") @@ -64,7 +66,8 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch): by = {p.chainage_m: p for p in points} assert by[100.0].options is None # 옛 빈칸 그대로 # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) - assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L"} + heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5) # 기본 관경 1000 → 1.5(139-1) + assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights} def test_월류_폭_기본값은_등록부_엔진과_같음(): diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index f96d33e2b..3048c6f79 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -466,11 +466,13 @@ def test_pipe_accessory_options_defined(): "outlet_pitching_area_m2", ): assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다" - # 기본 치수 — 길이 10m · 높이 1.5(10-6 배관 기본값 표 · 54-36 · 근거 default_basis). + # 기본 치수 — 길이 10m · 높이 = 관경 + 0.5(정의 고정값 없음 · 화면 · 서버가 관경으로 채움 · 139-1). for side in ("inlet", "outlet"): assert options[f"{side}_revet_length_m"].default == 10 - assert options[f"{side}_revet_height_m"].default == 1.5 + assert options[f"{side}_revet_height_m"].default is None + assert "관경 + 0.5" in options[f"{side}_revet_height_m"].default_basis assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means + assert options["inlet_basin_height_m"].default is None # 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail. for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"): assert options[key].phase == "b05", f"pipe.{key}는 b05 phase" diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index 22a6bd6f2..72bc4e8e1 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -43,14 +43,14 @@ def test_empty_options_have_no_members(): def test_saved_values_drive_members(): - """고른 값만 부재 — 빈 높이 = 관경 기준 최소(Ø1000 → 2.0) · 빈 형태 = 없음.""" + """고른 값만 부재 — 빈 높이 = 관경 기준(Ø1000 → 1.5 · 139-1) · 빈 형태 = 없음.""" spec = _culvert_set({"inlet_type": "집수정", "inlet_structure": "L", "outlet_type": "기슭막이"}) assert spec["inlet"]["basin_form"] == "L" assert spec["inlet"]["basin_height_m"] is None part = spec["outlet"] assert part["structure"] == "기슭막이" assert part["revet_form"] is None - assert part["revet_height_m"] == 2.0 + assert part["revet_height_m"] == 1.5 assert part["face_slope"] == REVET_FACE_SLOPE # 옛 보호공(apron) 칸은 걷음 — 유출 보호는 도수로/돌붙임 띠(90-2) assert "apron_length_m" not in part @@ -63,7 +63,7 @@ def test_basin_inlet_skips_revet_and_apron(): assert "revet_height_m" not in spec["inlet"] # 유출측은 그대로 세트를 갖춘다. assert spec["outlet"]["structure"] == "기슭막이" - assert spec["outlet"]["revet_height_m"] == 2.0 # 빈 높이 = 관경 기준 최소 + assert spec["outlet"]["revet_height_m"] == 1.5 # 빈 높이 = 관경 기준(139-1) def test_string_diameter_and_custom_height(): @@ -75,6 +75,29 @@ def test_string_diameter_and_custom_height(): assert spec["outlet"]["revet_height_m"] == 1.5 +def test_old_default_moves_for_large_pipe(): + """옛 기본 1.5 · 관경 > 1.0 = 관경 + 0.5 로 읽음(139-1) · 관경 ≤ 1.0 · 다른 값은 그대로.""" + big = _culvert_set( + { + "pipe_diameter_mm": 1200, + "inlet_type": "집수정", + "inlet_basin_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_height_m": 1.5, + } + ) + assert big["outlet"]["revet_height_m"] == 1.7 + assert big["inlet"]["basin_height_m"] == 1.7 + small = _culvert_set( + {"pipe_diameter_mm": 800, "outlet_type": "기슭막이", "outlet_revet_height_m": 1.5} + ) + assert small["outlet"]["revet_height_m"] == 1.5 + hand = _culvert_set( + {"pipe_diameter_mm": 1500, "outlet_type": "기슭막이", "outlet_revet_height_m": 1.6} + ) + assert hand["outlet"]["revet_height_m"] == 1.6 + + def test_chute_thickness_cm_to_m(): """도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음.""" assert "chute" not in _culvert_set({"chute_add_1_thickness": 40}) diff --git a/resources/tester/test_b06_height_unlock.py b/resources/tester/test_b06_height_unlock.py index 2761ee9c8..293737365 100644 --- a/resources/tester/test_b06_height_unlock.py +++ b/resources/tester/test_b06_height_unlock.py @@ -23,13 +23,14 @@ import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js"; const spec = { revet_form: "찰쌓기", revet_height_m: 1.5 }; const pipe = (h) => revetWallSpec(spec, { h, m: null }, false, 0.8).pureHeight; const own = (h) => revetWallSpec(spec, { h, m: null }, true, 0).pureHeight; +const blank = revetWallSpec({ revet_form: "찰쌓기" }, { h: null, m: null }, false, 0.8).pureHeight; const extras = buildOutletExtras({ start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1, groundAt: () => 80, limitOffset: 40, adjusts: [{ x: 0, d: 0, h: 0.3, m: null }], }); writeFileSync(process.argv[2], JSON.stringify({ - pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeZero: pipe(0), + pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeBlank: blank, pipeZero: pipe(0), ownLow: own(0.3), ownNeg: own(-1), extraCount: extras.walls.length, extraPure: extras.walls.map((w) => w.topBack.elevation - w.bottomBack.elevation), @@ -74,7 +75,8 @@ def test_높이는_입력_그대로_0이하만_멈춤(tmp_path: Path) -> None: got = json.loads(result.read_text(encoding="utf-8")) assert got["pipeLow"] == pytest.approx(0.7), "관 흙막이가 관경 기준 최소로 되돌아감" - assert got["pipeAuto"] == pytest.approx(1.8), "빈 칸 기본값(관경 + 1.0) 틀림" + assert got["pipeAuto"] == pytest.approx(1.5), "그림이 칸 값(1.5)을 안 읽음(139-1)" + assert got["pipeBlank"] == pytest.approx(1.3), "빈 칸 기본값(관경 + 0.5 · 139-1) 틀림" assert got["pipeZero"] == pytest.approx(0.1), "0 이하가 그림 하한에서 안 멈춤" assert got["ownLow"] == pytest.approx(0.3), "흙막이 0.3 이 근입 기준 최소로 되돌아감" assert got["ownNeg"] == pytest.approx(0.1), "음수 높이가 그림 하한에서 안 멈춤" diff --git a/resources/tester/test_b06_pipe_wall_height_mirror.py b/resources/tester/test_b06_pipe_wall_height_mirror.py index a41970e06..75707760c 100644 --- a/resources/tester/test_b06_pipe_wall_height_mirror.py +++ b/resources/tester/test_b06_pipe_wall_height_mirror.py @@ -26,18 +26,33 @@ def _ts_const(source: str, name: str) -> float: def test_상수_셋이_TS_와_같다() -> None: - for name in ("REVET_FREEBOARD_M", "REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"): + for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"): assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name + assert culvert.OLD_PIPE_WALL_DEFAULT_M == _ts_const(SETS, "OLD_PIPE_WALL_DEFAULT_M") def test_TS_식_모양이_그대로다() -> None: """식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것.""" - assert "const raw = diameterM + REVET_FREEBOARD_M;" in TS + assert "const raw = diameterM;" in TS and "const raw = diameterM;" in SETS assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS + # 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준 + assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM))" in TS + # 옛 1.5 옮김 — TS 세트 짝 식 + assert "Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 &&" in SETS + assert "diameterM > 1.0 + 1e-9" in SETS -def test_관경별_최소_높이() -> None: - assert culvert.pipe_wall_min_height_m(1.0) == 2.0 - assert culvert.pipe_wall_min_height_m(0.8) == 1.8 - assert culvert.pipe_wall_min_height_m(1.2) == 2.2 +def test_관경별_기본_높이() -> None: + """관경 + 0.5 = 땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면(139-1 · 10-6 표 Ø1000 = 1.5).""" + assert culvert.pipe_wall_min_height_m(1.0) == 1.5 + assert culvert.pipe_wall_min_height_m(0.8) == 1.3 + assert culvert.pipe_wall_min_height_m(1.2) == 1.7 + assert culvert.pipe_wall_min_height_m(1.5) == 2.0 + + +def test_옛_기본_옮김() -> None: + assert culvert.pipe_wall_height_m(1.5, 1.2) == 1.7 + assert culvert.pipe_wall_height_m(1.5, 1.0) == 1.5 + assert culvert.pipe_wall_height_m(1.6, 1.5) == 1.6 + assert culvert.pipe_wall_height_m(None, 1.2) is None From d3540038410c3878d1ff27be9311a5367286a5d1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:59:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B06):=20=EA=B4=80=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EA=B3=B5=20=EC=97=B0=EB=8F=99=20=ED=92=80=EB=A6=B0=20=EC=98=86?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EB=B2=BD=200.1=20m=20=EA=B1=B8?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=ED=91=B8=EB=8A=94=20=EC=88=9C=EA=B0=84=20?= =?UTF-8?q?4=EC=B6=95(x=20=C2=B7=20d=20=C2=B7=20h=20=C2=B7=20m)=20?= =?UTF-8?q?=EA=B5=B3=ED=9E=98=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=3D=20=EA=B5=B3=ED=9E=8C=20=EA=B0=92=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20[=EC=97=B0=EB=8F=99]=20=ED=95=9C=20=EA=B8=B8(139-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_UI_Cross_Culvert_Wire.ts | 30 +++-- .../B06_Section_UI_Cross_View_Structure.ts | 22 ++-- .../B06_Section_UI_Page_Link_Session.ts | 60 +++++++++- .../B06_Section_UI_Page_Station_Controls.ts | 16 ++- resources/tester/helper_139_2_link_freeze.cjs | 109 ++++++++++++++++++ resources/tester/test_139_2_link_freeze.py | 55 +++++++++ 6 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 resources/tester/helper_139_2_link_freeze.cjs create mode 100644 resources/tester/test_139_2_link_freeze.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 4d1bf1d74..3796e0126 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -409,16 +409,7 @@ export function computeCardCulvert( // 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다. // 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시 // 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자). - const ownerInput = adjustsInput(link.source, revetOffset, extraWalls); - // 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4). - const ownerLayout = computeCulvertLayout( - link.source, - link.source.samples, - ownerInput, - link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), - inletStructure?.adjustFor(link.source), - ); - const ownerApplied = ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; + const ownerApplied = ownerAppliedAdjusts(link.source, revetOffset, inletStructure, extraWalls); // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 // 벽만 자기 값으로 선다(2026-08-30 사용자). const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; @@ -599,6 +590,25 @@ function detachedAdjusts( }; } +/** 소유 측점이 실제로 적용한 4축 — 연동 카드가 물려받는 값 · 연동을 푸는 순간 굳히는 값(139-2). */ +export function ownerAppliedAdjusts( + owner: CrossSection, + revetOffset?: RevetOffsetControl, + inletStructure?: InletStructureControl, + extraWalls?: ExtraWallControl, +): WallAdjustSet { + const ownerInput = adjustsInput(owner, revetOffset, extraWalls); + // 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4). + const ownerLayout = computeCulvertLayout( + owner, + owner.samples, + ownerInput, + owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner), + inletStructure?.adjustFor(owner), + ); + return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; +} + function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index d88057f65..00fa535c1 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -150,8 +150,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). + // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => - (key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()), + (key !== "inlet" && key !== "outlet") || + (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), isLinkedCard: () => ctx.isLinked, @@ -203,20 +206,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe ctx.isLinked && ctx.drawnWallKeys().length ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } : null, - setLinked: (linked) => { - // 연동을 **푸는 순간** 지금 그려진 높이를 이 측점 값으로 굳힌다(2026-09-06 사용자 - // 지시). 예전에는 위치(4축)만 갈리고 높이는 소유 측점 값을 계속 따라가, 소유 - // 측점 높이를 바꾸면 푼 측점까지 같이 움직였다. 굳혀 두면 값이 튀지 않으면서 - // 이후에는 따로 논다. 단 수·형태는 소유 측점을 그대로 따른다(2026-08-30 확정 유지). - if (!linked) { - ctx.drawnWallKeys().forEach((key) => { - if (ctx.adjustOf(key).h !== null && ctx.adjustOf(key).h !== undefined) return; - const height = ctx.heightOfWall(key); - if (height > 0) ctx.revetOffset?.update(ctx.section.chainage_m, key, { h: height }); - }); - } - ctx.revetLink?.setLinked(ctx.section.chainage_m, linked); - }, + // 푸는 순간 4축(x · d · h · m) 굳힘 · 다시 연동 = 지움 — 제어기 `setLinked` 한 길(139-2 · + // 좌측 관 보호공 [연동]과 같음). 단 수 · 형식은 소유 측점을 그대로 따름(2026-08-30). + setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked), followGrade: () => ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true, setFollowGrade: (follow) => { diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index c12715271..edafcd95a 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -10,12 +10,21 @@ * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { + computeCardCulvert, + culvertLinkFor, culvertOwnerFor, culvertReach, culvertWallsStandAt, + ownerAppliedAdjusts, + type ExtraWallControl, + type InletStructureControl, type RevetLinkControl, + type RevetOffsetControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ @@ -111,10 +120,59 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag }; } +/** + * 연동을 푸는 순간(카드 [연동] · 좌측 [연동] 둘 다 · 139-2) — 그 카드가 그리던 벽마다 4축을 이 + * 측점 값으로 굳힘: 위치(x · d) = 소유 측점 적용값(물려받던 값) · 높이 · 형태 = 그 카드가 그린 값. + * d 가 차서 이 측점 지형으로 자동 자리 · 관 정수 맞춤을 다시 타지 않음(튐 없음). 연동 중에 부름. + */ +export function freezeLinkedWalls( + section: CrossSection, + sections: readonly CrossSection[], + c: { + revetOffset: RevetOffsetControl; + inletStructure?: InletStructureControl; + extraWalls?: ExtraWallControl; + revetLink: RevetLinkControl; + }, +): void { + const link = section.culvert ? undefined : culvertLinkFor(section, sections, () => null); + const owner = + link && ownerAppliedAdjusts(link.source, c.revetOffset, c.inletStructure, c.extraWalls); + if (!link || !owner) return; + const card = computeCardCulvert( + section, + section.samples, + null, + c.revetOffset, + c.inletStructure, + c.extraWalls, + link, + c.revetLink, + ); + const drawn = card ? culvertCardState(card).wallSpecs : new Map(); + const roles: Array<[RevetKey, WallAdjust]> = [ + ["inlet", owner.inlet], + ["outlet", owner.outlet], + ...owner.extras.map((a, i): [RevetKey, WallAdjust] => [`extra${i}` as RevetKey, a]), + ...owner.basinExtras.map((a, i): [RevetKey, WallAdjust] => [`bextra${i}` as RevetKey, a]), + ]; + for (const [role, applied] of roles) { + const wall = drawn.get(role); + if (!wall) continue; + const round3 = (v: number): number => Math.round(v * 1000) / 1000; + c.revetOffset.update(section.chainage_m, role, { + x: round3(applied.x), + d: applied.d == null ? null : round3(applied.d), + h: wall.height, + m: wall.form, + }); + } +} + /** * 좌측 관 보호공 [연동] · [경사](54-30) — 옛 연동 제어 그대로 읽고 씀. 연동 = 벽이 서는 옆 측점이 * 있을 때만 · 켬 = 그 측점이 모두 연동 · 끄면 모두 따로. 경사 = 보호공 구간이 있을 때만. - * ponytail: 연동을 풀 때 카드 단추가 하던 높이 굳히기는 안 함 — 필요하면 카드 높이 셈을 여기로. + * 풀 때 굳히기 · 다시 걸 때 지우기는 `setLinked` 가 함(139-2 · 카드 단추와 한 길). */ export function pipeRevetChoiceBridge(revetLink: RevetLinkControl) { const linkCards = (owner: CrossSection, sections: readonly CrossSection[]) => diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index fd3fc9e73..79bc25659 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -26,7 +26,11 @@ import type { import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; -import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session"; +import { + createLinkFlagSession, + createSessionMap, + freezeLinkedWalls, +} from "./B06_Section_UI_Page_Link_Session"; import { createSpanControl } from "./B06_Section_UI_Page_Span_Control"; import type { FordAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; @@ -528,6 +532,16 @@ export function createStationControls(deps: StationControlDeps): StationControls return section.design?.revet_link_detached !== true; }, setLinked: (chainageM, linked) => { + // 푸는 순간 그린 4축을 이 측점 값으로 굳힘 · 다시 연동 = 굳힌 값 지움(139-2) + const section = sectionAt(chainageM); + if (linked) dropLinkedAdjusts(chainageM); + else if (section && revetLinkControl.linkedFor(section)) + freezeLinkedWalls(section, deps.detail()?.cross_sections ?? [], { + revetOffset: revetOffsetControl, + inletStructure: inletStructureControl, + extraWalls: extraWallControl, + revetLink: revetLinkControl, + }); linkDetached.set(chainageKey(chainageM), !linked); persistLinkFlags(); patchCachedDesign(chainageM, { revet_link_detached: !linked }); diff --git a/resources/tester/helper_139_2_link_freeze.cjs b/resources/tester/helper_139_2_link_freeze.cjs new file mode 100644 index 000000000..bb5657f8f --- /dev/null +++ b/resources/tester/helper_139_2_link_freeze.cjs @@ -0,0 +1,109 @@ +/* 139-2 연동 풀림 굳힘 헬퍼 — 화면 TS(`_Page_Link_Session.freezeLinkedWalls` · `_Cross_Culvert_Wire`)를 + * 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 73-4 픽스처(박스 352.14 흙막이 · 옆 측점 340 · 360). + * 짝 = `test_139_2_link_freeze.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const noop = new Proxy({}, { get: () => () => "" }); +// 조정창 모듈이 불러올 때 문서 이벤트를 검 — 기하엔 안 쓰임 +globalThis.document = { addEventListener: () => undefined }; +globalThis.window = globalThis; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + // 화면 전용(토스트 · 글 · 설정) — 기하엔 안 쓰임 + if (request.startsWith("@ui/") || request.startsWith("@config/")) return noop; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`].find((p) => fs.existsSync(p) && fs.statSync(p).isFile()) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { freezeLinkedWalls } = loadTs(B06("B06_Section_UI_Page_Link_Session.ts")); +const { computeCardCulvert, culvertLinkFor } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire.ts")); + +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +).cross_sections; +const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6); + +// 제어기 흉내 — 측점:역할 → 4축(세션 맵과 같은 꼴) +const stored = new Map(); +const keyOf = (c, role) => `${c.toFixed(2)}:${role}`; +const ZERO = { x: 0, d: null, h: null, m: null }; +const revetOffset = { + adjustFor: (s, role) => ({ ...ZERO, ...(stored.get(keyOf(s.chainage_m, role)) ?? {}) }), + storedAdjustFor: (s, role) => stored.get(keyOf(s.chainage_m, role)) ?? null, + update: (c, role, patch) => + stored.set(keyOf(c, role), { ...ZERO, ...(stored.get(keyOf(c, role)) ?? {}), ...patch }), + reset: (c, role) => stored.delete(keyOf(c, role)), + syncApplied: () => undefined, + selectedFor: () => null, + highlightFor: () => null, + select: () => undefined, +}; +const extraWalls = { countFor: () => 0, consumeEqualize: () => false }; +let linked = true; +const revetLink = { linkedFor: () => linked, followGradeFor: () => true }; +const controls = { revetOffset, extraWalls, revetLink }; + +const round = (v) => Math.round(v * 1000) / 1000; +const shape = (s) => { + const link = culvertLinkFor(s, sections, () => null); + const layout = computeCardCulvert( + s, + s.samples, + null, + revetOffset, + undefined, + extraWalls, + link, + revetLink, + ); + return (layout?.walls ?? []).map((w) => ({ + role: w.role, + points: w.points.map((p) => [round(p.offset), round(p.elevation)]), + })); +}; + +const owner = at(352.14); +const card = at(360); +// 소유 측점 벽을 조금 옮겨 둠 — 옆 카드가 물려받는 자리가 자동 자리가 아니게 +revetOffset.update(owner.chainage_m, "outlet", { x: 0.7, h: 2.3 }); +const linkedShape = shape(card); +freezeLinkedWalls(card, sections, controls); +const frozen = Object.fromEntries( + [...stored].filter(([k]) => k.startsWith("360.00:")).map(([k, v]) => [k.slice(7), v]), +); +linked = false; +const detachedShape = shape(card); +// 푼 뒤 소유 측점을 또 옮김 — 푼 카드는 안 따라감 +revetOffset.update(owner.chainage_m, "outlet", { x: 2.0 }); +const afterOwnerMove = shape(card); +linked = true; // 대조 — 연동이면 소유 측점 이동을 따라감(시험이 실제로 재는지) +const linkedAfterMove = shape(card); +process.stdout.write( + JSON.stringify({ linkedShape, frozen, detachedShape, afterOwnerMove, linkedAfterMove }), +); diff --git a/resources/tester/test_139_2_link_freeze.py b/resources/tester/test_139_2_link_freeze.py new file mode 100644 index 000000000..c8c6f66d2 --- /dev/null +++ b/resources/tester/test_139_2_link_freeze.py @@ -0,0 +1,55 @@ +"""139-2 관보호공 연동 풀림 — 옆 측점 벽 0.1 m 걸음 · 푸는 순간 4축 굳힘 · 다시 연동 = 지움. + +화면 TS 를 그 자리에서 돌린 값(`helper_139_2_link_freeze.cjs` · 73-4 픽스처 박스 352.14 흙막이 · 옆 측점 360). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_139_2_link_freeze.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_푸는_순간_4축이_차고_그림은_그대로(out: dict) -> None: + assert set(out["frozen"]) == {"inlet", "outlet"} + for adjust in out["frozen"].values(): + assert isinstance(adjust["x"], (int, float)) + assert isinstance(adjust["d"], (int, float)) # d 가 차서 자동 자리 · 정수 맞춤 안 탐 + assert adjust["h"] > 0 and adjust["m"] + assert out["frozen"]["outlet"]["h"] == pytest.approx(2.3) # 소유 측점 높이 그대로 + assert out["detachedShape"] == out["linkedShape"] # 튐 없음 + + +def test_푼_뒤엔_소유_측점_이동을_안_따라감(out: dict) -> None: + assert out["afterOwnerMove"] == out["detachedShape"] + assert out["linkedAfterMove"] != out["linkedShape"] # 대조 — 연동이면 따라감 + + +def test_한_길_걸음_다시_연동_지움() -> None: + stc = (ROOT / "B06_Section" / "B06_Section_UI_Page_Station_Controls.ts").read_text( + encoding="utf-8" + ) + vs = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View_Structure.ts").read_text( + encoding="utf-8" + ) + # 카드 [연동] · 좌측 [연동] 모두 제어기 setLinked 로 — 풀면 굳힘 · 걸면 지움 + assert "if (linked) dropLinkedAdjusts(chainageM);" in stc + assert "freezeLinkedWalls(section" in stc + assert "setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked)" in vs + # 풀린 옆 측점 카드 = 0.1 m 걸음 + assert "(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false)" in vs