Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

# Conflicts:
#	B08_Quantity/B08_Quantity_Engine_Pipe.py
This commit is contained in:
2026-09-09 12:10:09 +09:00
10 changed files with 659 additions and 20 deletions
+45
View File
@@ -199,6 +199,50 @@ def _merge_irregular_into_longitudinal(
atomic_write_json(path, data)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json"
def sampling_snapshot_path(project_root: Path) -> Path:
return project_root / "B06_Section" / SAMPLING_SNAPSHOT_NAME
def save_sampling_snapshot(project_root: Path, request: RouteConfirmRequest) -> None:
"""확정 때 쓴 지표 샘플링 조건을 남긴다(2026-09-09).
왜 — 관을 나중에 놓으면 그 측점이 안 생기는데(계획서 3-14), 나중에 만들려면 **그때와 같은
조건**으로 떠야 한다. 조건을 안 남기면 되짚을 길이 없어 **지어내야 하는 자리**가 된다.
"""
if not request.filter_key or not request.method:
return
path = sampling_snapshot_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{
"filter_key": request.filter_key,
"method": request.method,
"smooth": bool(request.smooth),
"surface_model_id": request.surface_model_id,
},
)
def load_sampling_snapshot(project_root: Path) -> dict[str, Any] | None:
"""남겨 둔 샘플링 조건. 없으면 `None` — **지어내지 않는다**(노선 확정을 한 번 더 받는다)."""
path = sampling_snapshot_path(project_root)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not data.get("filter_key") or not data.get("method"):
return None
return data
async def _append_irregular_cross_sections(
connection: aiomysql.Connection,
project_id: UUID,
@@ -208,6 +252,7 @@ async def _append_irregular_cross_sections(
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
+147 -2
View File
@@ -459,7 +459,17 @@
"abbr": "개거"
},
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
"options": []
"options": [
{
"key": "ditch_spec",
"label": "규격",
"input": "select",
"choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"],
"default": null,
"required": false,
"phase": "detail"
}
]
},
{
"type_id": "ditch_side",
@@ -709,7 +719,7 @@
"key": "form",
"label": "형식",
"input": "select",
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"],
"default": null,
"required": true,
"phase": "detail"
@@ -720,6 +730,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -838,6 +866,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -956,6 +1002,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1148,6 +1212,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1238,6 +1320,33 @@
"required": true,
"phase": "detail"
},
{
"key": "spillway",
"label": "방수로",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "back_len_cm",
"label": "뒷길이 3",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
@@ -1313,6 +1422,24 @@
"input": "number",
"unit": "m",
"default": 2.0
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1576,6 +1703,24 @@
"default": 5,
"required": false,
"phase": "b05"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
+19 -1
View File
@@ -431,6 +431,24 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if not sets:
return 0
attached = 0
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
owner: dict[float, float] = {}
for pipe_chainage in sets:
nearest = None
for section in cross_sections:
value = _number(section.get("chainage_m"), None)
if value is None:
continue
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
nearest = value
if nearest is not None:
owner[pipe_chainage] = nearest
for section in cross_sections:
chainage = _number(section.get("chainage_m"), None)
if chainage is None:
@@ -440,7 +458,7 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
reach = _CHAINAGE_TOLERANCE_M
if spec.get("type") in _SPAN_LINKED_TYPES:
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
+247
View File
@@ -0,0 +1,247 @@
"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯).
무엇이 문제였나
측점을 만드는 자리는 **B05 노선 [확정] 곳뿐**이다. 관을 저장하는
`PUT /drainage/pipe-points` 파일과 유역만 쓰고 측점을 다시 만들지 않는다.
**관을 나중에 놓거나 옮기면 측점이 생긴다.** 관은 횡단도에도 서고
수량·금액에서 통째로 빠지는데 **아무 말도 나온다**(실측: 배수관 ).
방식인가 ( 갈래 )
저장 바로 만들기 엔드포인트에 노선·지표면 인자가 없어 끌어와야
**알리고 [측점 만들기] 단추** 누를 때만 돌아 비용이 적고 ** 값이 없는지가 보임**
그대로 두기 조용히 빠지는 것이 문제라 적어도 알림은 있어야
**지어내지 않는 ** 지표 샘플링 조건(어느 DTM·어느 방법) 없으면 만들지 않는다.
조건이 다르면 측점만 다른 지표에서 뽑혀 ** 측점과 지반고가 어긋난다.** 조건은
노선 확정 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
#: 「그 자리에 측점이 있다」고 볼 거리(m).
#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리).
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0
#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`).
#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.**
#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다.
STATION_MATCH_TOLERANCE_M = 0.5
SNAPSHOT_MISSING = (
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
" 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남"
)
async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None:
"""이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다.
노선 [확정] 남긴 `B06_Section/sampling.json` **그때 실제로 조건**이라 1순위.
없으면 1단계(지표 확정) 저장값 B06 화면 `context` 쓰는 값이라 같은 조건이다.
프로젝트는 없으므로 길이 없으면 단추가 영영 돈다.
"""
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
snapshot = load_sampling_snapshot(project_root)
if snapshot is not None:
return snapshot
params = await run_with_connection(get_surface_confirmation_params, str(project_id))
if not params or not params.get("source_filter") or not params.get("method"):
return None
return {
"filter_key": params["source_filter"],
"method": params["method"],
"smooth": bool(params.get("smooth")),
"surface_model_id": None,
"source": "stage1",
}
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
polyline = _load_route_polyline(project_root, route_data_path)
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
if not extras:
return []
existing = _station_chainages(project_root)
missing = []
for chainage, label in extras:
value = float(chainage)
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
continue
missing.append({"chainage_m": round(value, 3), "label": label})
return sorted(missing, key=lambda item: item["chainage_m"])
def _station_chainages(project_root: Path) -> list[float]:
"""종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록."""
import json
folder = project_root / "B06_Section" / "longitudinal"
values: list[float] = []
for path in sorted(folder.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
chainage = station.get("chainage_m")
if isinstance(chainage, (int, float)):
values.append(float(chainage))
return values
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
route = await get_latest_route(connection, project_id)
return stored, route
stored, route = await run_with_connection(_load)
if not stored or not route:
return None
return Path(resolve_stored_project_path(stored)), route
@router.get("/{project_id}/section/missing-stations")
async def get_missing_stations(project_id: UUID) -> JSONResponse:
"""측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다."""
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(content={"status": "success", "missing": [], "can_create": False})
project_root, route = paths
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
snapshot = await _sampling_conditions(project_id, project_root)
except Exception:
logger.exception("B06 측점 점검 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 점검하지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
"missing": missing,
"can_create": bool(missing) and snapshot is not None,
"reason": "" if snapshot is not None else SNAPSHOT_MISSING,
}
)
@router.post("/{project_id}/section/missing-stations")
async def create_missing_stations(project_id: UUID) -> JSONResponse:
"""빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다.
구조물 측점 전체를 다시 뜬다 종단 병합이 비정규 측점을 **통째로 교체**하므로
빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다.
"""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Router_Confirm import (
_merge_irregular_into_longitudinal,
_section_options_from_stored,
)
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409,
content={"status": "error", "message": SNAPSHOT_MISSING},
)
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
if not missing:
return JSONResponse(content={"status": "success", "created": 0, "missing": []})
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
def _regenerate() -> int:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
stations = generate_irregular_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
if stations and longitudinal:
_merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations)
made = await asyncio.to_thread(_regenerate)
except Exception:
logger.exception("B06 측점 만들기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 만들지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
# 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다.
"created": len(missing),
"regenerated": made,
"missing": missing,
}
)
+33 -2
View File
@@ -61,6 +61,27 @@ const inletStructure: InletStructureControl = {
resetAdjust: () => undefined,
};
/**
* ** ** . `null`.
*
* ( ).
* ** ** .
*/
function pipeOwnerChainage(
section: CrossSection,
sections: readonly CrossSection[],
): number | null {
const target = section.culvert?.chainage_m;
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
best = item.chainage_m;
}
}
return best;
}
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
const design = section.design;
@@ -136,10 +157,20 @@ function areaRowOf(
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
//
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
const pipeOwner =
!!section.culvert &&
(typeof section.culvert.chainage_m !== "number" ||
Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
@@ -0,0 +1,99 @@
/* =============================================================================
* B06_Section_UI_Missing_Stations.ts
* N개 + [ ] ( 3-14 ).
*
* **B05 [] **,
* . **·
* **(실측: 배수관 B09 ).
*
* ( ).
* ** ** .
* ** **
* .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
interface MissingStation {
chainage_m: number;
label: string;
}
interface MissingResponse {
missing?: MissingStation[];
can_create?: boolean;
reason?: string;
created?: number;
message?: string;
}
async function call(projectId: string, method: "GET" | "POST"): Promise<MissingResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const payload = (await response.json()) as MissingResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/**
* `host` . .
* `onCreated` ( ).
*/
export async function mountMissingStationNotice(
host: HTMLElement,
projectId: string,
onCreated: () => void | Promise<void>,
): Promise<void> {
let data: MissingResponse;
try {
data = await call(projectId, "GET");
} catch {
return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다.
}
const missing = data.missing ?? [];
if (!missing.length) return;
const box = document.createElement("div");
box.className = "b06-missing-stations";
const text = document.createElement("p");
text.className = "b06-missing-stations__text";
const where = missing
.slice(0, 6)
.map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`)
.join(" · ");
text.textContent =
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
(missing.length > 6 ? `${missing.length - 6}` : "") +
". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.";
box.append(text);
if (data.can_create) {
const button = createButton({ label: "측점 만들기", variant: "filled" });
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "만드는 중…";
try {
const result = await call(projectId, "POST");
showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success");
box.remove();
await onCreated();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`측점을 만들지 못했습니다.${detail}`, "error");
button.disabled = false;
button.textContent = "측점 만들기";
}
});
box.append(button);
} else if (data.reason) {
const reason = document.createElement("p");
reason.className = "b06-missing-stations__reason";
reason.textContent = data.reason;
box.append(reason);
}
host.prepend(box);
}
+4
View File
@@ -4,6 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
@@ -750,6 +751,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
} catch (error) {
@@ -416,3 +416,29 @@
.b06-chart__spoil-fill.is-unclosed {
stroke: #c0392b;
}
/* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */
.b06-missing-stations {
align-items: center;
background: #fff8e6;
border: 1px solid #e0b872;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 12px;
padding: 10px 12px;
}
.b06-missing-stations__text {
color: #7a5a12;
flex: 1 1 320px;
margin: 0;
}
.b06-missing-stations__reason {
color: #8a6a22;
flex: 1 1 100%;
font-size: 12px;
margin: 0;
}
+21 -14
View File
@@ -46,17 +46,24 @@ NOTE_SECTION_MISSING = (
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
)
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
# ⚠ **맞추지 말 것.** 하나로 맞추면 둘 다 나빠진다(2026-09-09 세 창 확인).
# · `LENGTH_MATCH_TOLERANCE_M` (0.5m) — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` (0.05m) — 「이 측점에 **횡단 설계가 있나**」.
# 좁아야 한다. 없는 설계를 「있다」고 말하면 사용자가 [저장]을 눌러 보고 헤맨다.
# ⚠ 두 값의 차이는 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
#
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
# 바뀌면 한쪽만 움직여야 한다.
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
# 계획서 3-14), 그것이 고쳐지면 의 차이가 **아무 관도 안 건드린다**
# (자기 자리에 측점이 있는 관 다섯이 이미 그 상태다).
# 계획서 3-14), 그것이 고쳐지면 들이 **아무 관도 안 건드린다.**
LENGTH_MATCH_TOLERANCE_M = 0.5
SECTION_MATCH_TOLERANCE_M = 0.05
SECTION_MATCH_TOLERANCE_M = 0.5
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
NOTE_KIND_UNKNOWN = "{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
@@ -89,7 +96,7 @@ def _nearest(
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
좁게 본다(기본 0.5m) 넓히면 측점의 길이를 물어 조용히 틀린다.
횡단이 있나 재는 `SECTION_MATCH_TOLERANCE_M`(0.05m) **다른 물음**이다
횡단이 있나 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m) **다른 물음**이다
주석을 . 맞추면 나빠진다.
"""
if not lengths:
@@ -141,10 +148,10 @@ def build_rows(
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
length = _nearest(lengths, chainage)
# ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다.
# 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로
# 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다.
# 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤).
# ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) —
# 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게
# 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로
# 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다.
has_section = not sections or any(
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
)
+18 -1
View File
@@ -329,6 +329,23 @@ export function attachCulvertSets(
): number {
if (sets.size === 0) return 0;
let attached = 0;
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이
// 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다.
// ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.**
// ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`).
const owner = new Map<number, number>();
for (const [pipeChainage] of sets) {
let nearest: number | null = null;
for (const section of sections) {
const value = num(section.chainage_m, null);
if (value === null) continue;
if (nearest === null || Math.abs(value - pipeChainage) < Math.abs(nearest - pipeChainage)) {
nearest = value;
}
}
if (nearest !== null) owner.set(pipeChainage, nearest);
}
for (const section of sections) {
const chainage = num(section.chainage_m, null);
if (chainage === null) continue;
@@ -336,7 +353,7 @@ export function attachCulvertSets(
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
let reach = CHAINAGE_TOLERANCE_M;
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
if (Math.abs(chainage - pipeChainage) <= reach) {
if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) {
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.