diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index fa414226f..7f8987fae 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -29,7 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import ( structure_type_map, ) from common_util.common_util_drainage_pipes import ( - PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, PIPE_FACILITY_REVET, PipePoint, @@ -208,9 +207,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: - """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" - if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: - return None + """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름.""" if pipe.facility == PIPE_FACILITY_PIPE: values = pipe.options or {} kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) @@ -267,8 +264,7 @@ def resolve_extra_stations( (2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다 — B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다. - 물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다 - (2026-08-28 사용자 확정). + 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1). 라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 되짚기 때문이다(B05_Profile_Structures_Migration). diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 57f637776..4b8f39300 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -112,12 +112,6 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }); } - /** 물넘이포장은 비정규 측점이 아니다 — 횡단도로 표현할 것이 없어 종단 알약으로만 - * 다룬다(2026-08-28 사용자 확정). 관 정본 대조(setPipeChainages)는 전체 목록을 써야 - * 물넘이가 지워지지 않으므로, **표시·서버 전송 쪽에서만** 뺀다. */ - const withoutFordPavement = (stations: IrregularStation[]): IrregularStation[] => - stations.filter((station) => station.structure !== FACILITY_NAMES.ford_pavement); - /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { pipeStations = []; @@ -144,7 +138,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }); irregularStations = stations; deps.renderStationLines(); - deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); + deps.profilePanel().setIrregularStations(stations); // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. deps @@ -190,18 +184,13 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이 * 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을 - * 지우지 않게) · 물넘이포장은 측점을 안 심어 견주지 않음. */ + * 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */ function syncCrossDrainStations(): void { - const planting = (station: IrregularStation) => - station.structure !== FACILITY_NAMES.ford_pavement; const kept: IrregularStation[] = []; for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { const taken = station.origin !== "pipe" && - kept.some( - (other) => - planting(other) && Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M, - ); + kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M); if (!taken) kept.push(station); } applyIrregularStations(kept); @@ -410,7 +399,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }); }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ - irregularStations: () => withoutFordPavement(irregularStations), + irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 07ceb2d77..0ff9bc61a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -179,7 +179,7 @@ export function structureIdKeeping( return keeps ? selectedId : structureIdAtStation(stationId, stations, structures); } -/** 제 측점을 심는 알약인가 — 272 측점 심기 한 벌(A군 · C · D군 구간형 · 관은 물넘이포장 뺌). +/** 제 측점을 심는 알약인가 — 272 측점 심기 한 벌(A군 · C · D군 구간형 · 관 = 물넘이포장까지 · 286-1). * 안 심는 구조물 알약은 같은 자리 · 근처 측점선을 짝으로 켜지 않음(요구 178) · 종류를 모르면 옛 길. */ export function plantsStation( structure: StructureInstance, @@ -194,7 +194,7 @@ export function plantsStation( return !type || structureStationMarks(structure, type).length > 0; } -/** 알약(구조물) id → 측점선 id. 제 측점을 안 심는 구조물(B군 · 물넘이포장 등)이면 null. */ +/** 알약(구조물) id → 측점선 id. 제 측점을 안 심는 구조물(B군 · 표지 등)이면 null. */ export function stationIdAtStructure( structureId: string | null, stations: IrregularStation[], diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index b4eb74dcc..5454d6385 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -96,7 +96,6 @@ function decorate( const byId = new Map(types.map((type) => [type.type_id, type])); const pipeLabel = (pipe: Pipes[number]): string | null => { const facility = pipe.facility ?? "pipe"; - if (facility === "ford_pavement") return null; if (facility !== "pipe") return byId.get(facility)?.name ?? facility; const values = { ...registry.pipe, ...pipe.options }; return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 9173d08f4..67465f56d 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -147,8 +147,8 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri const spec = section[key] as Record | undefined; if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; } - // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음) - // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때) + // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 그 밖 = 종류 이름) + // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점 const pipe = stale.find((entry) => { const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; @@ -156,8 +156,7 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri if (!pipe) continue; const facility = pipe.facility ?? "pipe"; const values = { ...registry.pipe, ...pipe.options }; - if (facility === "ford_pavement") delete section.structure; - else if (facility === "pipe") + if (facility === "pipe") section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility; } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 84955effb..817c0fed9 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -94,21 +94,19 @@ function pipeStations( types: ReadonlyArray, intervalM: number, ): IrregularStation[] { - return marks - .filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement") - .map((mark) => { - const chainage = structureAnchorM(mark); - const station = Math.floor(chainage / intervalM); - return { - id: String(mark.structure_id), - station, - remainder: chainage - station * intervalM, - chainage_m: chainage, - structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", - structureType: "배관", - origin: "pipe", - } as IrregularStation; - }); + return marks.filter(isPipeMark).map((mark) => { + const chainage = structureAnchorM(mark); + const station = Math.floor(chainage / intervalM); + return { + id: String(mark.structure_id), + station, + remainder: chainage - station * intervalM, + chainage_m: chainage, + structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", + structureType: "배관", + origin: "pipe", + } as IrregularStation; + }); } /** diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index c6a31545d..ac0cb7d52 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -111,10 +111,9 @@ function marksOf( ); } -/** 관 시설 하나가 심는 측점 — 물넘이포장 없음 · 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖 = 기준. */ +/** 관 시설 하나가 심는 측점 — 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖(물넘이포장 포함 · 286-1) = 기준. */ export function pipeStationMarks(pipe: PlantingPipe): StationMark[] { const facility = pipe.facility ?? "pipe"; - if (facility === "ford_pavement") return []; return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, facility === "revetment"); } diff --git a/resources/tester/helper_284_1_pill_pick.cjs b/resources/tester/helper_284_1_pill_pick.cjs index 620fe68de..1b7dd660f 100644 --- a/resources/tester/helper_284_1_pill_pick.cjs +++ b/resources/tester/helper_284_1_pill_pick.cjs @@ -63,11 +63,12 @@ const STRUCTURES = [ S("pipe-80.00", "pipe", 80), S("pipe-120.00", "ford_pavement", 120), ]; -// 종단도 구조물 측점(관 투영 + 심은 구조물) — 물넘이포장은 측점 없음 +// 종단도 구조물 측점(관 투영 + 심은 구조물) — 물넘이포장도 측점 심음(286-1) const STATIONS = [ { id: "drain", chainage_m: 40, origin: "structure" }, { id: "pipe-80.00", chainage_m: 80, origin: "pipe" }, { id: "wall", chainage_m: 100, origin: "structure" }, + { id: "pipe-120.00", chainage_m: 120, origin: "pipe" }, ]; const byId = (id) => STRUCTURES.find((s) => s.structure_id === id); diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs new file mode 100644 index 000000000..8cbdde1bd --- /dev/null +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -0,0 +1,30 @@ +/* 286 물넘이포장 종횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 입력 = argv[2] JSON 파일 { pipes, structures, types }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts")); + +const out = { + fordMarks: station.pipeStationMarks({ chainage_m: 230, facility: "ford_pavement" }), + planted: station.plantedChainages(input.pipes, input.structures, input.types), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index f4a03d24b..82181a3d4 100644 --- a/resources/tester/test_272_1_station_list.py +++ b/resources/tester/test_272_1_station_list.py @@ -1,7 +1,7 @@ """272-1 측점 한 벌 — 화면(TS)과 서버(파이썬)가 같은 측점 목록을 냄(요구 167 · 측점 겹침 근본). - 심는 자리: TS `plantedChainages` ↔ 서버 `resolve_extra_stations`(관 · 독립 기슭막이 시작 · - 기준 · 끝 · A군 · C군 구간형 시작 · 기준 · 끝 · 물넘이포장 · 다른 군 · 점형 C/D 는 안 심음). + 기준 · 끝 · A군 · C군 구간형 시작 · 기준 · 끝 · 물넘이포장 기준(286-1) · 다른 군 · 점형 C/D 는 안 심음). - 같은 m = 측점 하나 · 구조물이 위 · 떠나면 일반 측점: TS `oneStationList` ↔ 서버 `_merge_irregular_into_longitudinal`. 진짜 TS 는 `helper_272_1_station_list.cjs` 가 Node 로 돌림. @@ -66,7 +66,7 @@ STRUCTURES = [ }, {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, ] -EXPECTED = [95, 120, 160, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560] +EXPECTED = [95, 120, 160, 230, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560] # 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · # 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 diff --git a/resources/tester/test_284_1_pill_pick.py b/resources/tester/test_284_1_pill_pick.py index c63114265..60eef37e8 100644 --- a/resources/tester/test_284_1_pill_pick.py +++ b/resources/tester/test_284_1_pill_pick.py @@ -2,8 +2,8 @@ """284 종단도 알약 고름 · 풂(요구 178 · 179-1 · 조사 AM). 못박는 것 - - 측점 있음 = 제 측점을 심는 알약(272 한 벌 — A군 · C · D 구간형 · 관은 물넘이포장 뺌) · 종류 모르면 옛 길. - - 측점 없는 알약(B군 · 표지 · 바닥막이 · 물넘이포장)은 같은 자리 · 근처 측점선을 짝으로 켜지 않음. + - 측점 있음 = 제 측점을 심는 알약(272 한 벌 — A군 · C · D 구간형 · 관 = 물넘이포장까지 · 286-1) · 종류 모르면 옛 길. + - 측점 없는 알약(B군 · 표지 · 바닥막이)은 같은 자리 · 근처 측점선을 짝으로 켜지 않음. - 관 측점선을 다시 누르면 측점 없는 표지가 아니라 그 관 알약. - B06 알약 다시 누름 = 풀기 한 길(Esc 값) · 측점 없는 관 = 카드 안 고름 · 스크롤만 · 고름 값에 알약 id. - B05 물넘이포장 알약 = 고름 값에 알약 id(같은 m 일반 측점선 안 켬). @@ -48,7 +48,7 @@ def test_측점_심기_규칙(out): "sill": False, # D군 점 "unknown": True, # 종류 모름 = 옛 길 "pipe-80.00": True, # 관 - "pipe-120.00": False, # 물넘이포장 + "pipe-120.00": True, # 물넘이포장(286-1 측점 심음) } @@ -56,8 +56,8 @@ def test_측점_없는_알약은_측점선_짝_없음(out): line = out["line"] assert line["drain"] == "irregular:drain" and line["wall"] == "irregular:wall" assert line["pipe-80.00"] == "irregular:pipe-80.00" - # 표지는 관 측점(80)과 같은 m 여도 그 선을 안 켬 · 물넘이포장 · B군 · D군 점도 없음 - assert line["sign"] is None and line["pipe-120.00"] is None + # 표지는 관 측점(80)과 같은 m 여도 그 선을 안 켬 · B군 · D군 점도 없음 · 물넘이포장 = 제 측점선(286-1) + assert line["sign"] is None and line["pipe-120.00"] == "irregular:pipe-120.00" assert line["ditch"] is None and line["sill"] is None assert out["lineNoTypes"] == "irregular:pipe-80.00" # 종류 없이 부르면 옛 길 diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py new file mode 100644 index 000000000..41018dbb9 --- /dev/null +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -0,0 +1,84 @@ +"""286 물넘이포장 종횡단 설계(요구 180-2). + +못박는 것 + - 286-1 물넘이포장도 기준 측점 하나 — TS `pipeStationMarks` · `plantedChainages` ↔ 서버 + `resolve_extra_stations` 같은 값 · 라벨 「물넘이포장」 · B05 · B06 거름 걷음. + +진짜 TS 는 `helper_286_1_ford_pavement.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 + +PIPES = [ + {"chainage_m": 95.0, "source": "user"}, + {"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"}, + {"chainage_m": 300.0, "source": "user", "facility": "ford_bridge"}, +] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + types = [ + { + "type_id": item.type_id, + "group": item.group, + "placement": item.placement, + "managed_by": item.managed_by, + } + for item in structure_type_map().values() + ] + source = tmp_path_factory.mktemp("s286") / "in.json" + source.write_text( + json.dumps({"pipes": PIPES, "structures": [], "types": types}), encoding="utf-8" + ) + proc = subprocess.run( + ["node", str(HERE / "helper_286_1_ford_pavement.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_물넘이포장_측점_TS_파이썬_같은_값(ts, tmp_path): + assert ts["fordMarks"] == [{"chainage_m": 230, "role": "anchor"}] + server = resolve_extra_stations(tmp_path, parse_pipe_points(PIPES)) + assert [mark for mark, _label in server] == ts["planted"] == [95, 230, 300] + assert dict(server)[230.0] == "물넘이포장" + + +def test_B05_B06_물넘이_거름_걷음(): + page = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") + assert "withoutFordPavement" not in page + assert "FACILITY_NAMES.ford_pavement" not in page + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert '"ford_pavement"' not in view + drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") + assert 'if (facility === "ford_pavement") return null;' not in drafts diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index fd7c24c47..f6d078ee0 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -87,11 +87,12 @@ def test_pipe_without_options_falls_back_to_config_defaults(tmp_path): assert extras == ((30.0, "파형강관 D800"),) -def test_ford_pavement_is_not_an_irregular_station(tmp_path): - """물넘이포장은 비정규 측점이 아니다 — 종단 그래프 구조물 표시 몫(2026-08-28 사용자 확정).""" +def test_ford_pavement_plants_its_station(tmp_path): + """물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면(286-1).""" points = [{"chainage_m": 40.0, "facility": "ford_pavement"}, {"chainage_m": 60.0}] extras = resolve_extra_stations(tmp_path, parse_pipe_points(points)) - assert [chainage for chainage, _label in extras] == [60.0] + assert [chainage for chainage, _label in extras] == [40.0, 60.0] + assert extras[0][1] == "물넘이포장" # ── 구조물 정본 A군 합류 ─────────────────────────────────────────────────── diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py index d31a94891..01c5919e1 100644 --- a/resources/tester/test_b05_pipe_station_relabel.py +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -49,7 +49,7 @@ def test_relabel_follows_saved_kind(tmp_path): 85.05: "BOX암거", 120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로 173.09: "흄관 D1200", - 258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음) + 258.12: "물넘이포장", # 물넘이포장도 종류 이름(286-1 · 측점 생성 규칙과 같음) }