From a622bcb9641ba4148050db932c7748a8a7ab6910 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:40:03 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(=EC=84=B8=EC=85=98):=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=B3=80=EA=B2=BD=20=EB=95=8C=20=EC=98=9B=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=C2=B7=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EA=B0=80=20=EC=95=88=20=EC=A7=80=EC=9B=8C=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20(=EA=B3=84=ED=9A=8D=EC=84=9C=200-7=20=EA=B3=81?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노선을 다시 계산하면 세션의 옛 노선치가 그대로 쌓였음. 용화(5601e828)에 145·161·169 세 노선치가 남아 있는 것을 실측함. 원인은 「안 지웠다」가 아니라 **못 지웠다** — 노선 변경 자리는 새 번호를 아직 모르니 `clearDrafts(projectId)` 로 부르는데, `stateKey` 가 노선 번호 없이는 `null` 을 내어 route 범위 키가 한 개도 안 나갔음. 부르는 쪽 주석은 「남기지 않는다」였고 실제로는 다 남았음. `clearState` 한 곳에서, 노선 범위인데 노선 번호가 없으면 `aislo:<통>:<이름>:<프로젝트>:` 로 시작하는 키를 모두 쓸어 내게 함. `clearDrafts` 와 `clearResults` 가 함께 나음. 노선 번호를 주면 종전대로 그 한 벌만 나감. 안전 확인 — 옛 노선치를 읽는 자리 없음(`readState` 호출 8곳 전부 전역·프로젝트 범위). 노선 복원도 새 route id 를 발급하므로 옛 키는 다시 안 불림. 자체검증 — `tmp/tests/test_page_state_route_sweep.py` 3건 신설. 옛 코드로 같은 시험을 돌리면 세 벌이 그대로 남아 깨지는 것까지 확인(헛도는 시험 아님). 전체 511 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 57a6c426..7c6e06ed 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -278,6 +278,27 @@ export function clearState( projectId?: string | null, routeId?: number | string | null, ): void { + const entry = entryOf(name); + // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. + // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), + // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 + // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — + // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. + if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + try { + const doomed: string[] = []; + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + if (key && key.startsWith(prefix)) doomed.push(key); + } + doomed.forEach((key) => writeRaw(key, null)); + } catch { + /* 무시 — 저장소가 막힌 브라우저 */ + } + return; + } writeStateRaw(name, null, projectId, routeId); } @@ -290,7 +311,8 @@ export function namesInBucket(bucket: StateBucket): StateName[] { /** * 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다 - * (예전에는 파일마다 따로 지웠다). 노선을 모르면 노선 범위 초안은 남는다. + * (예전에는 파일마다 따로 지웠다). 노선을 안 주면 그 프로젝트의 **모든 노선** 초안이 나간다 + * (노선 변경 자리가 그렇게 부른다 — 새 번호를 아직 모르기 때문). */ export function clearDrafts(projectId: string | null, routeId?: number | string | null): void { namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId)); From e2d329d35f686596cf8078113a38d470cfacd9b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:54:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD):?= =?UTF-8?q?=20=EC=A7=80=EB=AC=B8=EC=9D=B4=20=EB=8B=AC=EB=9D=BC=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=85=B8=EC=84=A0=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B4=80=20=EC=A7=80=EC=A0=90=EC=9D=84=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=94=80=20(=EA=B3=84=ED=9A=8D=EC=84=9C=200-7=20?= =?UTF-8?q?=EA=B3=81=EA=B0=80=EC=A7=80=20=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 노선인데 지문이 늘 어긋나 관 지점이 매번 투영 이월을 탔음. 까닭 — 같은 노선을 두 파일이 다른 자릿수로 담고 있음. `planned_route.csv` 는 소수 4자리(`208403.2001`), `route_main.geojson` 은 소수 3자리 (`208403.2`)로 csv 를 mm 반올림한 사본임. 좌표 차는 최대 0.5mm 뿐인데, 지문이 `f"{x:.2f}"` 로 0.01m 자리에서 끊는 탓에 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 바뀜 — 169개 중 16개가 그랬음. 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로, 글자 일치 대신 **허용오차**로 가름. 저장된 관을 지금 노선에 투영해 **재 보기만** 하고 (`max_projection_shift`, 값은 안 고침), 최대 어긋남이 0.05m 이하면 같은 노선으로 보고 저장분을 그대로 돌려줌. 허용오차 0.05m 근거 — 실측 어긋남이 최대 0.0053m 이라 다섯 배 이상 여유이고, 사람이 노선을 실제로 고치면 관은 m 단위로 밀리므로 「같다」로 볼 위험이 없음. 투영 이월 가지는 그대로 둠 — 대신 그 가지가 실제로 돌면 WARNING 을 찍게 함. 한동안 0 인 것을 확인한 뒤에야 지울 수 있음(먼저 지우면 관이 통째로 사라짐). 자체검증 - 새 시험 3건 `tmp/tests/test_pipe_route_tolerance.py` — mm 반올림 사본은 같은 노선으로 판정되고 누가거리가 한 값도 안 움직임 / 중간을 3m 민 노선은 안 걸림 / 허용오차 범위. - 기존 `test_pipe_point_projection.py` 2건 그대로 통과(±0.7m 잔물결 노선은 여전히 투영). - 저장된 실제 3개 프로젝트 전후 대조 — 셋 다 「다름 → 투영」이 「같음 → 저장분 그대로」로 바뀜. 화면 값 변화는 최대 0.0053m · 0.0009m · 0.0005m. - 전체 514 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_drainage_pipes.py | 54 ++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index b27ac82a..247572f4 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -160,6 +160,38 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) return points +# 같은 노선으로 볼 누가거리 어긋남의 한계(m). +# +# 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이 +# `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한 +# 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 +# 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다. +# +# 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신 +# 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측 +# **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로 +# 밀리므로 그것을 「같다」로 볼 위험은 없다. +ROUTE_MATCH_TOLERANCE_M = 0.05 + + +def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None: + """저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**). + + 좌표가 없는 관이 하나라도 있으면 잴 수 없어 None. + """ + if not vertices or not points: + return None + if any(point.x is None or point.y is None for point in points): + return None + line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + if line.length <= 0: + return None + return max( + abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m)) + for point in points + ) + + def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. @@ -214,10 +246,28 @@ def load_pipe_points_file( stored_signature = str(document.get("route_signature") or "") if stored_signature == signature: return points - if vertices and points and all(p.x is not None and p.y is not None for p in points): + # 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고 + # 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나 + # 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다. + shift = max_projection_shift(points, vertices) if vertices else None + if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M: logger.info( - "배수유역: 노선이 바뀌어 관 지점 %d건을 좌표로 이월합니다 (%s).", + "배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 " + "(최대 어긋남 %.4fm ≤ %.2fm, %s).", len(points), + shift, + ROUTE_MATCH_TOLERANCE_M, + path.name, + ) + return points + if vertices and points and all(p.x is not None and p.y is not None for p in points): + # ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야 + # 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다). + logger.warning( + "배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 " + "(최대 어긋남 %s, %s).", + len(points), + f"{shift:.3f}m" if shift is not None else "잴 수 없음", path.name, ) return project_pipe_points(points, vertices) From 7769db3639a247c5277824231663aadca797c727 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 13:39:42 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC):=20?= =?UTF-8?q?=EC=B8=A1=EA=B5=AC=EB=8A=94=20=E3=80=8C=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=E3=80=8D=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98=EA=B3=A0,=20B?= =?UTF-8?q?=EA=B5=B0=20=EC=97=B0=EC=9E=A5(m)=20=EC=A7=91=EA=B3=84=EB=A5=BC?= =?UTF-8?q?=20=EB=A7=8C=EB=93=A6=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 결정 두 가지를 반영함(2026-09-07). ① 측구(옆도랑) — 목록에 두되 표시만. 레지스트리에 `design_owner` 를 새로 두고 측구에 「횡단 설계」를 적음. 구조물 배치 폼에서 그 종류를 고르면 「횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.」가 뜸. `managed_by` 와 달리 저장은 그대로 되므로 배치는 계속 가능함. 까닭 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적(`ditch_area_m2`)을 파이썬·TS 짝으로 이미 셈함. 구조물로 또 세면 같은 것을 두 번 계상함. 목록 항목에 안 붙이고 폼에 붙인 것은 「한 항목 = [측점][이름]만」이 2026-08-18 사용자 지시이기 때문임. ② B군 수량 1단계 — 구조물 정본에서 시설별 연장(m)을 냄. `common_util_structure_lengths.py` 하나. B군 수량 단위가 전부 m 이라 단면 기하가 필요 없음(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2). 수량서 양식에 안 묶이므로 B08 을 실무 xlsx 양식으로 다시 짜도 그대로 씀. 빼는 것 셋 — 관 정본 소관(`managed_by`), 횡단 소관(`design_owner`), 그리고 소단측구 (놓일 소단이 아직 없음 — 계획서 3-9 뒤에 채움). 겹친 구간은 합쳐서 셈함. 같은 시설을 겹치게 두 번 넣으면 단순 합이 그 구간을 두 번 세기 때문임. 원래 합(`raw_length_m`)도 함께 내보내 겹침이 숨지 않게 함. 자체검증 - 새 시험 7건 `tmp/tests/test_structure_lengths.py` — 종류별 합산 / 겹침 제거(80m 인데 단순 합 110m) / 측구 제외 / 소단측구 제외 / 배관 제외 / 구간 없는 항목은 스키마가 막음 / 빈 프로젝트. - 화면 확인(8001·5174, `/api/health` `stale:false`) — 구조물군 B → 종류 측구 선택 시 안내가 254×44px 로 뜨고 문구가 맞음. 리셋으로 폼 되돌림. - 전체 521 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 4 +- B05_Profile/B05_Profile_Structure_Types.json | 1 + B05_Profile/B05_Profile_Structures_Schema.py | 8 ++ B05_Profile/B05_Profile_UI_Structures_Form.ts | 20 +++- .../B05_Profile_UI_Structures_Panel.ts | 7 ++ .../B05_Profile_UI_Style_Structures.css | 11 +++ common_util/common_util_structure_lengths.py | 92 +++++++++++++++++++ 7 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 common_util/common_util_structure_lengths.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index d892eea8..f7f83289 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -48,6 +48,9 @@ export interface StructureType { drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; + /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. + * 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */ + design_owner: string | null; reference_only: boolean; enabled: boolean; } @@ -212,7 +215,6 @@ export function pipesToStructureMarks( })) as StructureInstance[]; } - export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 9a59af11..f6862a92 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -448,6 +448,7 @@ "enabled": false, "group": "B", "name": "측구(옆도랑)", + "design_owner": "횡단 설계", "placement": "interval", "style": { "color": "#2eaadc", diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 1afe9e16..d570578e 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -65,6 +65,14 @@ class StructureType(BaseModel): drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None + # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 + # (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고, + # ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다. + # + # 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로 + # (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면 + # **같은 것을 두 번 계상**한다(2026-09-07 조사). + design_owner: str | None = None # 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다. reference_only: bool = False enabled: bool = True diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index d7410144..f09bc1cf 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,6 +28,8 @@ export interface StructuresFormElements { body: HTMLElement; groupSelect: HTMLSelectElement; typeSelect: HTMLSelectElement; + /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ + ownerNote: HTMLElement; startFields: StationFields; anchorFields: StationFields; endFields: StationFields; @@ -96,6 +98,13 @@ export function buildStructuresForm(options: { typeRow.className = "b05-structure__grid"; typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); + // 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라 + // (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자: + // 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다. + const ownerNote = document.createElement("p"); + ownerNote.className = "b05-structure__owner-note"; + ownerNote.hidden = true; + // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-structure__grid"; @@ -129,13 +138,22 @@ export function buildStructuresForm(options: { const positionDivider = document.createElement("hr"); positionDivider.className = "b05-structure__divider"; - body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); + body.append( + typeRow, + ownerNote, + positionRow, + positionDivider, + optionRow, + facilityOptions.root, + actions, + ); return { root, body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 10c1395d..6363fb7f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, @@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; + // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 + // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). + ownerNote.textContent = type?.design_owner + ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` + : ""; + ownerNote.hidden = !type?.design_owner; anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 0097f181..0339812c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -194,6 +194,17 @@ border-top: 1px solid var(--color-border, #3a3f4a); } +/* 「제원·수량은 다른 화면이 냅니다」 안내 — 경고가 아니라 안내라 색은 흐리게, + * 왼쪽 선 하나로만 구분한다(2026-09-07). */ +.b05-structure__owner-note { + margin: 0; + padding: 4px 0 4px 8px; + border-left: 2px solid var(--color-border, #3a3f4a); + color: var(--color-text-muted, #9aa1ad); + font-size: var(--text-caption, 12px); + line-height: 1.5; +} + /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py new file mode 100644 index 00000000..092ff85e --- /dev/null +++ b/common_util/common_util_structure_lengths.py @@ -0,0 +1,92 @@ +"""구조물 정본에서 **시설별 연장(m)** 을 낸다 — B군 배수시설 수량의 입력 (계획서 3-6). + +왜 연장만인가 — B군(측구·산마루측구·도수로·맹암거 등)의 수량 단위가 **전부 m** 이다 +(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2 · 소단측구 12-9-3, 지식DB +`04_수량분석정보/배수공_수량.md`). 그래서 단면 기하 없이 구간 길이만으로 셈이 된다. +별표2의 「횡단면도 각 측점 기입 물량」 목록에도 B군은 없어 도면에 그릴 의무가 없다 +(2026-09-07 조사). + +**수량서 양식에 안 묶인다** — 여기서는 종류별 연장·개소만 내고, 어느 코드·어느 칸에 +넣을지는 B08 이 정한다. B08 은 실무 xlsx 양식으로 재작업 예정이라 그 사이에 두는 것이다. +""" + +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 +# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). +PENDING_TYPE_IDS = frozenset({"ditch_berm"}) + + +def _merge(spans: list[tuple[float, float]]) -> float: + """겹치는 구간을 합쳐 실제 덮인 길이를 낸다. + + 같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인 + 길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이 + 겹쳤다는 사실은 숨지 않는다. + """ + total = 0.0 + current_start: float | None = None + current_end = 0.0 + for start, end in sorted(spans): + if current_start is None: + current_start, current_end = start, end + continue + if start <= current_end: + current_end = max(current_end, end) + continue + total += current_end - current_start + current_start, current_end = start, end + if current_start is not None: + total += current_end - current_start + return total + + +def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: + """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). + + 빼는 것 셋 — + · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. + · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 + 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). + · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + + 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 + (`StructureInstance.validate_placement_fields`). + """ + types = structure_type_map() + spans: dict[str, list[tuple[float, float]]] = {} + counts: dict[str, int] = {} + + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.placement != "interval": + continue + if definition.managed_by or definition.design_owner: + continue + if structure.type_id in PENDING_TYPE_IDS: + continue + start, end = float(structure.start_m), float(structure.end_m) + counts[structure.type_id] = counts.get(structure.type_id, 0) + 1 + spans.setdefault(structure.type_id, []).append((min(start, end), max(start, end))) + + rows: list[dict[str, Any]] = [] + for type_id, count in counts.items(): + entries = spans[type_id] + rows.append( + { + "type_id": type_id, + "group": types[type_id].group, + "name": types[type_id].name, + "count": count, + # 겹침을 지운 실제 연장 — 수량서에 쓸 값. + "length_m": round(_merge(entries), 2), + # 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻. + "raw_length_m": round(sum(end - start for start, end in entries), 2), + } + ) + rows.sort(key=lambda row: (row["group"], row["name"])) + return rows From e43ad18f97a6d6535f81736ef8b5c7e875a13ac9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:54:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=A0=88?= =?UTF-8?q?=ED=86=A0=20=EC=82=AC=EB=A9=B4=EC=97=90=20=EC=86=8C=EB=8B=A8(?= =?UTF-8?q?=EA=B3=84=EB=8B=A8)=20=EA=B8=B0=ED=95=98=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=C2=B7TS=20?= =?UTF-8?q?=EC=A7=9D=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦. 이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임. 새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦. 암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에 실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 가장 적게 파는 조합임. 기본값은 되돌리기 쉬운 쪽이어야 함 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 함. 실효 경사로도 그러함(경사 1:1 기준) 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → 1:1.71. · 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시). 무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이 바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐). 자체검증 - 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS 꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것, 소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인. - 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값), 폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일. - ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs 7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도 틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼. - 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을 안 바꿨다는 증거임. 전체 537 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 82 ++++----- common_util/common_util_cross_berm.py | 157 ++++++++++++++++++ common_util/common_util_cross_berm.ts | 152 +++++++++++++++++ .../common_util_cross_design_geometry.ts | 73 ++++---- 4 files changed, 373 insertions(+), 91 deletions(-) create mode 100644 common_util/common_util_cross_berm.py create mode 100644 common_util/common_util_cross_berm.ts diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7cf2e4f8..75290112 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -34,6 +34,11 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_cut_areas, _trapezoid_areas, ) +from common_util.common_util_cross_berm import ( + BermSpec, + cut_profile_points, +) +from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -164,6 +169,7 @@ class _SectionGeometry: ditch_enabled: bool | None = None, widening_left_m: float = 0.0, widening_right_m: float = 0.0, + berm: BermSpec | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). @@ -186,7 +192,9 @@ class _SectionGeometry: ) self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 - self._rock_knee: dict[str, tuple[float, float] | None] = {} + # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. + self.berm = berm + self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} @@ -275,52 +283,25 @@ class _SectionGeometry: assert self._ground_at is not None # two_stage일 때만 호출 return self._ground_at(signed) + self._rock_offset - def knee(self, side: str) -> tuple[float, float] | None: - """절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시). + def cut_points(self, side: str) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. - 노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터 - 토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면 - None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다. + 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). + 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). """ - if not self.two_stage: - return None - if side in self._rock_knee: - return self._rock_knee[side] + if side in self._cut_points_cache: + return self._cut_points_cache[side] start_dist, start_z = self._slope_start(side) - diff_prev = start_z - self._rock_boundary_z(side, start_dist) - result: tuple[float, float] | None - if diff_prev >= 0: - result = (start_dist, start_z) # 시작부터 토사(경계 위) - else: - result = None - step = 0.05 - dist_prev = start_dist - dist = start_dist + step - while dist <= start_dist + 200.0: - z_rock = start_z + (dist - start_dist) / self.cut_ratio - diff = z_rock - self._rock_boundary_z(side, dist) - if diff >= 0: - span = diff - diff_prev - ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0 - knee_dist = dist_prev + (dist - dist_prev) * ratio - knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio - result = (knee_dist, knee_z) - break - dist_prev, diff_prev = dist, diff - dist += step - self._rock_knee[side] = result - return result + boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None + points = cut_profile_points( + start_dist, start_z, self.cut_ratio, self.soil_cut_ratio, boundary, self.berm + ) + self._cut_points_cache[side] = points + return points def _cut_slope_z(self, side: str, dist: float) -> float: - """절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" - start_dist, start_z = self._slope_start(side) - knee = self.knee(side) if self.two_stage else None - if knee is not None: - knee_dist, knee_z = knee - if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 - return start_z + (dist - start_dist) / self.cut_ratio - return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만 - return start_z + (dist - start_dist) / self.cut_ratio + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -439,15 +420,20 @@ class _SectionGeometry: return max(fill_line, ground_m) def breakpoints(self) -> list[float]: - """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함).""" + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) - if self.two_stage: + # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if self.two_stage or self.berm is not None: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role - knee = self.knee(side) if role == "cut" else None - if knee is not None: - points.append(knee[0] if side == "left" else -knee[0]) + if role != "cut": + continue + cross = self.cut_cross_dist(side) + for offset, _z in self.cut_points(side): + if cross is not None and offset > cross + 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + points.append(offset if side == "left" else -offset) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role @@ -490,6 +476,7 @@ def compute_cross_design( plan_radius_m: float | None = None, curve_outer_side: str | None = None, curve_widening_m: float | None = None, + berm: BermSpec | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -577,6 +564,7 @@ def compute_cross_design( ditch_enabled=ditch_enabled, widening_left_m=widening_left, widening_right_m=widening_right, + berm=berm, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py new file mode 100644 index 00000000..d0f28997 --- /dev/null +++ b/common_util/common_util_cross_berm.py @@ -0,0 +1,157 @@ +"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). + +짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 +`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. + +**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. +지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 +겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신 +**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 +값이 나온다(거울 시험이 그것도 지킨다). + +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. + 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. +· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 + **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +""" + +import math +from typing import Callable, NamedTuple + +# 소단 기본값 — 근거는 위 모듈 설명. +BERM_DEFAULT_WIDTH_M = 0.5 +BERM_DEFAULT_INTERVAL_M = 3.0 +BERM_DEFAULT_SLOPE_DEG = 2.0 + +# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. +_STEP_M = 0.05 +_MAX_REACH_M = 200.0 + + +class BermSpec(NamedTuple): + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + + width_m: float = BERM_DEFAULT_WIDTH_M + interval_m: float = BERM_DEFAULT_INTERVAL_M + slope_deg: float = BERM_DEFAULT_SLOPE_DEG + + +def cut_profile_points( + start_dist: float, + start_z: float, + cut_ratio: float, + soil_cut_ratio: float, + rock_boundary_z: Callable[[float], float] | None, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. + + `rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다. + 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** + 꺾는다. + + ⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러 + 번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도 + 같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가 + 통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**. + + `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. + 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 + (물이 노면 쪽으로 흐르게 — 소단측구를 놓는 자리다). + + 꼭짓점만 돌려준다 — 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다. + """ + points: list[tuple[float, float]] = [(start_dist, start_z)] + dist, elevation = start_dist, start_z + slant_since_berm = 0.0 + limit = start_dist + max_reach_m + berm_rise = ( + math.tan(math.radians(berm.slope_deg)) * berm.width_m + if berm is not None and berm.width_m > 0 + else 0.0 + ) + # 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist) + ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio + + while dist < limit: + rise = _STEP_M / ratio + slant = math.hypot(_STEP_M, rise) + + # ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: + remain = max(berm.interval_m - slant_since_berm, 0.0) + run = remain / math.hypot(1.0, 1.0 / ratio) + dist += run + elevation += run / ratio + points.append((dist, elevation)) # 소단 안쪽 모서리 + dist += berm.width_m + elevation += berm_rise + points.append((dist, elevation)) # 소단 바깥 모서리 + slant_since_berm = 0.0 + continue + + next_dist = dist + _STEP_M + next_z = elevation + rise + + # ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if rock_boundary_z is not None and not in_soil: + diff_now = elevation - rock_boundary_z(dist) + diff_next = next_z - rock_boundary_z(next_dist) + if diff_next >= 0: + span = diff_next - diff_now + share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 + share = min(max(share, 0.0), 1.0) + knee_dist = dist + _STEP_M * share + knee_z = elevation + rise * share + slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) + dist, elevation = knee_dist, knee_z + points.append((dist, elevation)) # 무릎 + in_soil = True + ratio = soil_cut_ratio + continue + + dist, elevation = next_dist, next_z + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + +def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게.""" + out: list[tuple[float, float]] = [] + for point in points: + if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9: + continue + out.append(point) + return out + + +def elevation_at(points: list[tuple[float, float]], dist: float) -> float: + """꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장.""" + if not points: + return 0.0 + if dist <= points[0][0]: + return points[0][1] + for index in range(1, len(points)): + x0, z0 = points[index - 1] + x1, z1 = points[index] + if dist > x1 + 1e-12: + continue + span = x1 - x0 + if span <= 1e-12: + return z1 + return z0 + (z1 - z0) * ((dist - x0) / span) + # 끝을 넘어가면 마지막 두 점의 기울기로 잇는다. + x0, z0 = points[-2] if len(points) > 1 else points[-1] + x1, z1 = points[-1] + span = x1 - x0 + if span <= 1e-12: + return z1 + return z1 + (z1 - z0) / span * (dist - x1) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts new file mode 100644 index 00000000..d9a669b4 --- /dev/null +++ b/common_util/common_util_cross_berm.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * common_util/common_util_cross_berm.ts + * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) + * + * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. + * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * + * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. + * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. + * + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. + * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * ========================================================================== */ + +/** 소단 기본값 — 근거는 위 설명. */ +export const BERM_DEFAULT_WIDTH_M = 0.5; +export const BERM_DEFAULT_INTERVAL_M = 3.0; +export const BERM_DEFAULT_SLOPE_DEG = 2.0; + +/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ +const STEP_M = 0.05; +const MAX_REACH_M = 200.0; + +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +export interface BermSpec { + widthM: number; + intervalM: number; + slopeDeg: number; +} + +export function bermSpec( + widthM = BERM_DEFAULT_WIDTH_M, + intervalM = BERM_DEFAULT_INTERVAL_M, + slopeDeg = BERM_DEFAULT_SLOPE_DEG, +): BermSpec { + return { widthM, intervalM, slopeDeg }; +} + +/** + * 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로. + * + * `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는 + * 자리에서 암 → 토사로 **한 번** 꺾는다. + * + * ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는 + * 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07). + */ +export function cutProfilePoints( + startDist: number, + startZ: number, + cutRatio: number, + soilCutRatio: number, + rockBoundaryZ: ((dist: number) => number) | null, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, +): Array<[number, number]> { + const points: Array<[number, number]> = [[startDist, startZ]]; + let dist = startDist; + let elevation = startZ; + let slantSinceBerm = 0; + const limit = startDist + maxReachM; + const bermRise = + berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; + // 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); + let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; + + while (dist < limit) { + const rise = STEP_M / ratio; + const slant = Math.hypot(STEP_M, rise); + + // ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { + const remain = Math.max(berm.intervalM - slantSinceBerm, 0); + const run = remain / Math.hypot(1, 1 / ratio); + dist += run; + elevation += run / ratio; + points.push([dist, elevation]); // 소단 안쪽 모서리 + dist += berm.widthM; + elevation += bermRise; + points.push([dist, elevation]); // 소단 바깥 모서리 + slantSinceBerm = 0; + continue; + } + + const nextDist = dist + STEP_M; + const nextZ = elevation + rise; + + // ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if (rockBoundaryZ !== null && !inSoil) { + const diffNow = elevation - rockBoundaryZ(dist); + const diffNext = nextZ - rockBoundaryZ(nextDist); + if (diffNext >= 0) { + const span = diffNext - diffNow; + let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; + share = Math.min(Math.max(share, 0), 1); + const kneeDist = dist + STEP_M * share; + const kneeZ = elevation + rise * share; + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = true; + ratio = soilCutRatio; + continue; + } + } + + dist = nextDist; + elevation = nextZ; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + +/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ +function dedupe(points: Array<[number, number]>): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const point of points) { + const last = out[out.length - 1]; + if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) { + continue; + } + out.push(point); + } + return out; +} + +/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */ +export function elevationAt(points: Array<[number, number]>, dist: number): number { + if (points.length === 0) return 0; + if (dist <= points[0][0]) return points[0][1]; + for (let index = 1; index < points.length; index += 1) { + const [x0, z0] = points[index - 1]; + const [x1, z1] = points[index]; + if (dist > x1 + 1e-12) continue; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z0 + (z1 - z0) * ((dist - x0) / span); + } + const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1]; + const [x1, z1] = points[points.length - 1]; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z1 + ((z1 - z0) / span) * (dist - x1); +} diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 1ff67234..bb9cdbc1 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,9 +6,10 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ +import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; const CROSS_MAX_M = 500; export interface ResolvedGroup { @@ -87,7 +88,9 @@ export class SectionGeometry { ditchPoints: Array<[number, number]> = []; private groundAt: ((offsetM: number) => number) | null; private rockOffset: number; - private rockKnee = new Map(); + /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ + berm: BermSpec | null = null; + private cutPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -203,50 +206,27 @@ export class SectionGeometry { return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; } - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); + /** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */ + cutPoints(side: string): Array<[number, number]> { + const cached = this.cutPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; + const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null; + const points = cutProfilePoints( + startDist, + startZ, + this.cutRatio, + this.soilCutRatio, + boundary, + this.berm, + ); + this.cutPointsCache.set(side, points); + return points; } - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + /** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; + return elevationAt(this.cutPoints(side), dist); } /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ @@ -354,11 +334,16 @@ export class SectionGeometry { breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { + // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if (this.twoStage || this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + for (const [offset] of this.cutPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + points.push(side === "left" ? offset : -offset); + } } } for (const side of ["left", "right"]) { From 5a6617f27888915524351f0650beb5fd25bf90b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:58:18 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(=ED=9A=A1=EB=8B=A8):=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=EC=9D=B4=20=EC=95=94=20=EA=B2=BD=EA=B3=84=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EB=A1=9C=20=EB=90=98=EB=8F=8C=EC=95=84=EA=B0=80?= =?UTF-8?q?=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20=EC=95=94=20=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=BC=20(=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배분 창 지적으로 확인한 자리 — 미룰 수 없는 쪽이었음. 무엇이 문제였나 — 소단은 평탄한데 암 경계선은 지반을 따라 올라감. 지반이 1:1 이면 폭 0.5m 소단 하나를 지나는 동안 경계는 0.5m 오르고 소단은 2°(0.017m)만 오름. 그래서 설계선이 경계 아래로 **되돌아 들어가는 일이 흔함**. 무릎을 한 번만 꺾는 종전 규칙으로는 그 구간을 **암인데 토사 경사로** 그려 절토가 조용히 커짐 — 경고도 안 뜸. 고침 — `multi_knee` 를 둬서 경계를 오갈 때마다 꺾게 함. **소단이 있을 때만** 켬. 소단이 없을 때 켜면 지금 측점들의 설계가 같이 바뀌기 때문임(실측: 물결 경계 0.0016m). 기본값은 거짓이라 소단을 안 쓰는 프로젝트는 그대로임. 자체검증 - 새 시험 1건 — 경계를 뚫고 올라갔다가 소단마다 뒤처져 되돌아 들어가는 지형에서 한 번만 꺾는 결과와 갈리고, 되돌아 들어간 구간이 암 경사(가파름)라 같은 거리에서 더 높이 올라가는 것을 확인. - 거울 시험에 되돌아 들어가는 경우를 한 줄 더해 파이썬·TS 일치 확인(12건). - 전체 539 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 11 +++++++- common_util/common_util_cross_berm.py | 27 ++++++++++++------- common_util/common_util_cross_berm.ts | 13 +++++---- .../common_util_cross_design_geometry.ts | 3 +++ 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 75290112..2ea1e9ba 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -294,7 +294,16 @@ class _SectionGeometry: start_dist, start_z = self._slope_start(side) boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None points = cut_profile_points( - start_dist, start_z, self.cut_ratio, self.soil_cut_ratio, boundary, self.berm + start_dist, + start_z, + self.cut_ratio, + self.soil_cut_ratio, + boundary, + self.berm, + # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 + # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 + # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). + multi_knee=self.berm is not None, ) self._cut_points_cache[side] = points return points diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index d0f28997..02c69c73 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -48,6 +48,7 @@ def cut_profile_points( rock_boundary_z: Callable[[float], float] | None, berm: BermSpec | None, max_reach_m: float = _MAX_REACH_M, + multi_knee: bool = False, ) -> list[tuple[float, float]]: """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. @@ -55,10 +56,16 @@ def cut_profile_points( 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** 꺾는다. - ⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러 - 번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도 - 같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가 - 통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**. + `multi_knee` 가 거짓이면 **한 번만** 꺾는다(기존 규칙 그대로). 참이면 경계를 오갈 때마다 + 꺾는다. + + ⚠ **소단이 있으면 반드시 참이어야 한다.** 소단은 평탄한데 경계선은 지반을 따라 올라가므로, + 폭 0.5m 짜리 소단 하나만 지나도 설계선이 경계 **아래로 되돌아가는 일이 흔하다**(지반이 + 1:1 이면 경계는 0.5m 오르고 소단은 2°=0.017m 만 오른다). 한 번만 꺾으면 그 구간을 **암인데 + 토사 경사로** 그려 절토가 조용히 커진다 — 경고도 안 뜬다(2026-09-07 배분 창 지적으로 확인). + + 반대로 소단이 없을 때 참으로 두면 **지금 측점들의 설계가 같이 바뀐다**(실측: 물결 경계에서 + 0.0016m). 그래서 기본값은 거짓이고, 소단을 줄 때만 참으로 켠다. `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 @@ -99,11 +106,13 @@ def cut_profile_points( next_dist = dist + _STEP_M next_z = elevation + rise - # ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. - if rock_boundary_z is not None and not in_soil: + # ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + # `multi_knee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if rock_boundary_z is not None and (multi_knee or not in_soil): diff_now = elevation - rock_boundary_z(dist) diff_next = next_z - rock_boundary_z(next_dist) - if diff_next >= 0: + crossed = (diff_next >= 0) if not in_soil else (diff_next < 0) + if crossed: span = diff_next - diff_now share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 share = min(max(share, 0.0), 1.0) @@ -112,8 +121,8 @@ def cut_profile_points( slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) dist, elevation = knee_dist, knee_z points.append((dist, elevation)) # 무릎 - in_soil = True - ratio = soil_cut_ratio + in_soil = not in_soil + ratio = soil_cut_ratio if in_soil else cut_ratio continue dist, elevation = next_dist, next_z diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index d9a669b4..c4e0a192 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -57,6 +57,7 @@ export function cutProfilePoints( rockBoundaryZ: ((dist: number) => number) | null, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, + multiKnee = false, ): Array<[number, number]> { const points: Array<[number, number]> = [[startDist, startZ]]; let dist = startDist; @@ -90,11 +91,13 @@ export function cutProfilePoints( const nextDist = dist + STEP_M; const nextZ = elevation + rise; - // ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. - if (rockBoundaryZ !== null && !inSoil) { + // ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + // `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if (rockBoundaryZ !== null && (multiKnee || !inSoil)) { const diffNow = elevation - rockBoundaryZ(dist); const diffNext = nextZ - rockBoundaryZ(nextDist); - if (diffNext >= 0) { + const crossed = !inSoil ? diffNext >= 0 : diffNext < 0; + if (crossed) { const span = diffNext - diffNow; let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; share = Math.min(Math.max(share, 0), 1); @@ -104,8 +107,8 @@ export function cutProfilePoints( dist = kneeDist; elevation = kneeZ; points.push([dist, elevation]); // 무릎 - inSoil = true; - ratio = soilCutRatio; + inSoil = !inSoil; + ratio = inSoil ? soilCutRatio : cutRatio; continue; } } diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index bb9cdbc1..d9b9502f 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -219,6 +219,9 @@ export class SectionGeometry { this.soilCutRatio, boundary, this.berm, + undefined, + // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). + this.berm !== null, ); this.cutPointsCache.set(side, points); return points; From 061d550e552998de3fb23e9b020a193cde1a0d85 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 15:28:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=EC=9D=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=C2=B7?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=EC=97=90=20=EC=8B=A4=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B3=84=EB=8B=A8=EC=9D=B4=20?= =?UTF-8?q?=EC=84=9C=EA=B2=8C=20=ED=95=A8=20(=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계단이 값에만 있던 것을 화면까지 연결함. 배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고, `readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과 ② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음. 암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음. 실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고 계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움. · 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86) · 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임** · 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐 · 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로. `cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝). 법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는 1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음. 자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인. 전체 539 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 ++ B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Cross_Refresh.ts | 14 +++- B06_Section/B06_Section_Engine_Design.py | 61 +++++++++++++++++ B06_Section/B06_Section_Router.py | 1 + B06_Section/B06_Section_Router_Design.py | 19 ++++++ B06_Section/B06_Section_Router_HaulPlan.py | 4 +- B06_Section/B06_Section_Schema.py | 4 ++ B06_Section/B06_Section_UI_Page_Persist.ts | 21 ++++++ common_util/common_util_cross_design.ts | 8 +++ .../common_util_cross_design_geometry.ts | 68 +++++++++++++++++++ 11 files changed, 203 insertions(+), 4 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 59904f2b..ed3dff06 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. + * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 + * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ + berm: { bucket: "draft", scope: "route" }, /** 표준 횡단면 설정 패널의 편집값. * * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 626813ba..26e84d89 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -253,6 +253,8 @@ export async function previewCrossDesigns( fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; + /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ + berms?: Record; }, ): Promise { return requestJson( @@ -264,6 +266,7 @@ export async function previewCrossDesigns( standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + berms: options?.berms ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 5305e28d..602f9b3c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,8 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, @@ -162,6 +163,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). + const berms = readBermSession(projectId, input.routeId); + const bermAt = (chainageM: number): BermSpec | null => { + const spec = berms[rockKey(chainageM).toFixed(2)]; + return spec + ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + : null; + }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). const choices = crossDesignChoices(projectId, input.routeId); @@ -204,6 +214,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: choice?.paved ?? Boolean(design.paved), standard, + berm: bermAt(section.chainage_m), rockBoundaryOffsetM, twoStageSlope: choice?.two_stage_slope ?? @@ -243,6 +254,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + berms: readBermSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 2ea1e9ba..8a0fcf11 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,6 +27,7 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ +import math from collections.abc import Callable from typing import Any @@ -312,6 +313,64 @@ class _SectionGeometry: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.cut_points(side), dist) + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. + + 왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 + **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는 + 소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다. + + · 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다. + · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. + · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. + 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). + """ + segments: list[dict[str, Any]] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + points = self.cut_points(side) + sign = 1.0 if side == "left" else -1.0 + for index in range(1, len(points)): + start_d, start_z = points[index - 1] + end_d, end_z = points[index] + if cross is not None and start_d >= cross - 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + if cross is not None and end_d > cross: + # 지반과 만나는 점에서 구간을 자른다. + end_z = berm_elevation_at(points, cross) + end_d = cross + run = end_d - start_d + rise = end_z - start_z + if run <= 1e-9 or rise <= 1e-6: + continue # 길이 0·역방향은 검사 대상이 아니다 + if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: + # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. + berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m + if abs(rise - berm_rise) < 1e-9: + continue + material: str | None = None + if self.two_stage: + middle_d = (start_d + end_d) / 2.0 + middle_z = (start_z + end_z) / 2.0 + material = ( + "soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock" + ) + segments.append( + { + "side": side, + "ratio": round(run / rise, 4), + "rise_m": round(rise, 4), + "run_m": round(run, 4), + "start_offset_m": round(sign * start_d, 4), + "end_offset_m": round(sign * end_d, 4), + "material": material, + } + ) + return segments + def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -715,6 +774,8 @@ def compute_cross_design( ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, + # 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외). + "cut_slope_segments": geometry.cut_slope_segments(), } if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index c497a156..ff330c5b 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -553,6 +553,7 @@ async def preview_cross_designs( request.standard_cross_section, request.rock_boundary_offsets, project_root, + request.berms, ) await asyncio.to_thread(rebuild) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 2ca30aea..6cd07943 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -10,6 +10,12 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_cross_berm import ( + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + BermSpec, +) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -343,6 +349,7 @@ def recompute_designs_for_alignment( standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, + berms: dict[str, dict[str, float]] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -358,6 +365,17 @@ def recompute_designs_for_alignment( session_offsets[round(float(raw_key), 3)] = float(offset) except (TypeError, ValueError): continue + # 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로). + session_berms: dict[float, BermSpec] = {} + for raw_key, spec in (berms or {}).items(): + try: + session_berms[round(float(raw_key), 3)] = BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError, AttributeError): + continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) key = round(chainage, 3) @@ -380,6 +398,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=session_berms.get(key), **curve_widening_args(section), ) except (ValueError, KeyError): diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 21f2fdbe..77c26186 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -64,9 +64,7 @@ async def compute_haul_plan( {"haul_plan_for": result, "context": _mass_haul_context()}, ) except Exception: - logger.exception( - "유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id - ) + logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) return JSONResponse( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index db92a93d..9b8dac61 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -298,6 +298,10 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). + # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. + berms: dict[str, dict[str, float]] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 5c90c6fa..0642671d 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -52,6 +52,27 @@ export function readRockBoundarySession( return stored && typeof stored === "object" ? stored : {}; } +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; +} + +/** + * 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체. + * + * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 + * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). + */ +export function readBermSession( + projectId: string, + routeId: number, +): Record { + const stored = readState>("berm", projectId, routeId); + return stored && typeof stored === "object" ? stored : {}; +} + /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 0cfe08ea..83f060b0 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,10 +23,12 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ +import type { BermSpec } from "./common_util_cross_berm"; import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { CURVE_WIDENING_MAX_WIDTH_M, + type CutSlopeSegment, SectionGeometry, curveWideningM, type ResolvedGroup, @@ -88,6 +90,8 @@ export interface CrossDesignOptions { curveOuterSide?: "left" | "right" | null; /** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */ curveWideningM?: number | null; + /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; } export interface CrossDesignEdge { @@ -128,6 +132,8 @@ export interface CrossDesignResult { fill_ground_slope: number | null; ditch_area_m2: number; design_line: CrossDesignEdge[]; + /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ + cut_slope_segments: CutSlopeSegment[]; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -298,6 +304,7 @@ export function computeCrossDesign( rockBoundaryOffsetM, twoStageSlope: enableTwoStage, ditchEnabled: options.ditchEnabled ?? null, + berm: options.berm ?? null, }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -430,6 +437,7 @@ export function computeCrossDesign( fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), ditch_area_m2: round4(ditchArea), design_line: designLine, + cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index d9b9502f..87d84ae8 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -8,6 +8,22 @@ import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; +/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ +export interface CutSlopeSegment { + side: string; + ratio: number; + rise_m: number; + run_m: number; + start_offset_m: number; + end_offset_m: number; + material: string | null; +} + +/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */ +function round4(value: number): number { + return Math.round(value * 10000) / 10000; +} + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; const CROSS_MAX_M = 500; @@ -109,6 +125,8 @@ export class SectionGeometry { /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ wideningLeftM?: number; wideningRightM?: number; + /** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -127,6 +145,7 @@ export class SectionGeometry { params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, ); this.groundAt = params.groundAt; + this.berm = params.berm ?? null; this.rockOffset = params.rockBoundaryOffsetM ?? 0; this.ditchType = params.ditchType; // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). @@ -333,6 +352,55 @@ export class SectionGeometry { return Math.max(startZ - run / this.fillRatio, groundM); } + /** + * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사용. + * + * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 + * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. + */ + cutSlopeSegments(): CutSlopeSegment[] { + const segments: CutSlopeSegment[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + const points = this.cutPoints(side); + const sign = side === "left" ? 1 : -1; + for (let index = 1; index < points.length; index += 1) { + const [startD, startZ] = points[index - 1]; + let [endD, endZ] = points[index]; + if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + if (cross !== null && endD > cross) { + endZ = elevationAt(points, cross); + endD = cross; + } + const run = endD - startD; + const rise = endZ - startZ; + if (run <= 1e-9 || rise <= 1e-6) continue; + if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) { + const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM; + if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부) + } + let material: string | null = null; + if (this.twoStage) { + const middleD = (startD + endD) / 2; + const middleZ = (startZ + endZ) / 2; + material = middleZ >= this.rockBoundaryZ(side, middleD) ? "soil" : "rock"; + } + segments.push({ + side, + ratio: round4(run / rise), + rise_m: round4(rise), + run_m: round4(run), + start_offset_m: round4(sign * startD), + end_offset_m: round4(sign * endD), + material, + }); + } + } + return segments; + } + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent];