auto: 2026-08-29 18:42 (EOMSANGDON-HOME)
This commit is contained in:
@@ -443,19 +443,20 @@ def build_cross_drawing(
|
||||
if rock:
|
||||
entities.append(rock)
|
||||
|
||||
# 표는 단면 아래에 붙인다 (좌표는 mm).
|
||||
# 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면
|
||||
# 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다.
|
||||
center_x = ox + (x0 + x1) / 2.0 * CROSS_MM
|
||||
half_height = own_half_height * CROSS_MM + 5.0
|
||||
table_bottom = oy - half_height
|
||||
if quantity_table is not None:
|
||||
table_top = oy - half_height - 8.0
|
||||
entities.extend(
|
||||
_cross_table_entities(drawing_id, quantity_table, table_top, title_label, ox)
|
||||
_cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x)
|
||||
)
|
||||
table_bottom = table_top - cross_table_height()
|
||||
|
||||
# 외곽 테두리: 단면 범위와 표를 함께 감싼다.
|
||||
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
|
||||
center_x = ox + (x0 + x1) / 2.0 * CROSS_MM
|
||||
frame_top = oy + half_height + 4.0
|
||||
frame_bottom = table_bottom - 4.0
|
||||
corners = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -12,8 +11,6 @@ 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_Engine_Sections import prune_stale_cross_files
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_design,
|
||||
@@ -21,35 +18,27 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_longitudinal_section,
|
||||
merge_cross_section_design_by_round,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
build_cross_drawing,
|
||||
infer_station_interval,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
QUANTITY_VALUE_KEYS,
|
||||
extract_quantity_table,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_cross_sheet_plan,
|
||||
_drawing_list,
|
||||
_invalidate_drawing,
|
||||
_read_drawing,
|
||||
_recompute_confirmed_design,
|
||||
_store_confirmed_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingConfirmRequest,
|
||||
DesignDrawingConfirmResponse,
|
||||
DesignDrawingInvalidateResponse,
|
||||
DesignDrawingItem,
|
||||
DesignDrawingListResponse,
|
||||
DesignDrawingResponse,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
@@ -59,7 +48,6 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
_STAGE_DIR = "B07_DesignDetail"
|
||||
|
||||
|
||||
async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
|
||||
@@ -82,376 +70,6 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
|
||||
return route_id, root, longitudinal_path
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.")
|
||||
return payload
|
||||
|
||||
|
||||
def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]:
|
||||
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
||||
stations = longitudinal.get("stations")
|
||||
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
||||
files = sorted(cross_dir.glob("cross_*.json"))
|
||||
if valid_names:
|
||||
files = [path for path in files if path.name in valid_names]
|
||||
return files
|
||||
|
||||
|
||||
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
stations = longitudinal.get("stations", [])
|
||||
if not isinstance(stations, list):
|
||||
return {}
|
||||
return {
|
||||
round(float(station.get("chainage_m", 0))): station
|
||||
for station in stations
|
||||
if isinstance(station, dict)
|
||||
}
|
||||
|
||||
|
||||
def _design_root(project_root: Path) -> Path:
|
||||
return project_root / _STAGE_DIR
|
||||
|
||||
|
||||
def _read_manifest(project_root: Path) -> dict[str, Any]:
|
||||
path = _design_root(project_root) / "manifest.json"
|
||||
if not path.is_file():
|
||||
return {"drawings": {}}
|
||||
payload = _read_json(path)
|
||||
return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}}
|
||||
|
||||
|
||||
def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
stage_root = _design_root(project_root)
|
||||
stage_root.mkdir(parents=True, exist_ok=True)
|
||||
path = stage_root / "manifest.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int, dict[str, Any]] | None = None,
|
||||
) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
manifest_drawings = _read_manifest(project_root)["drawings"]
|
||||
# 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1).
|
||||
drawings = [
|
||||
DesignDrawingItem(
|
||||
id=str(chunk["id"]),
|
||||
kind="longitudinal",
|
||||
label=str(chunk["label"]),
|
||||
confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")),
|
||||
)
|
||||
for chunk in longitudinal_chunks(longitudinal)
|
||||
]
|
||||
for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs):
|
||||
chainages = sheet["chainages"]
|
||||
first = station_by_chainage.get(chainages[0], {})
|
||||
last = station_by_chainage.get(chainages[-1], {})
|
||||
span = str(first.get("label") or chainages[0])
|
||||
if len(chainages) > 1:
|
||||
span = f"{span}~{last.get('label') or chainages[-1]}"
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=sheet["id"],
|
||||
kind="cross",
|
||||
label=f"{sheet['number']}장 ({span})",
|
||||
chainage_m=float(first.get("chainage_m", chainages[0])),
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
return drawings
|
||||
|
||||
|
||||
def _cross_sheet_plan(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int, dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
|
||||
|
||||
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
|
||||
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
blocks: list[tuple[int, float, float]] = []
|
||||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||||
match = _CROSS_ID.fullmatch(path.stem)
|
||||
if not match:
|
||||
continue
|
||||
chainage = int(match.group(1))
|
||||
source = _read_json(path)
|
||||
design = (designs or {}).get(chainage)
|
||||
design_line = _cross_design_line(longitudinal_path, source, design)
|
||||
width, height = section_block_size(source, design_line)
|
||||
blocks.append((chainage, width, height))
|
||||
return plan_cross_sheets(blocks)
|
||||
|
||||
|
||||
def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
|
||||
"""횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키).
|
||||
|
||||
center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값.
|
||||
나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로
|
||||
두어 CAD 테이블에서 사용자가 채운다.
|
||||
"""
|
||||
|
||||
def num(value: Any) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
ground = num(source.get("center_z"))
|
||||
planned = num(source.get("planned_elevation_m", source.get("design_elevation_m")))
|
||||
cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None
|
||||
fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None
|
||||
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
|
||||
|
||||
table: dict[str, float | None] = {
|
||||
"ground": ground,
|
||||
"planned": planned,
|
||||
"cut": cut,
|
||||
"fill": fill,
|
||||
}
|
||||
for key in QUANTITY_VALUE_KEYS:
|
||||
table.setdefault(key, num(quantities.get(key)))
|
||||
return table
|
||||
|
||||
|
||||
def _route_cross_frame(
|
||||
longitudinal_path: Path, longitudinal: dict[str, Any]
|
||||
) -> dict[str, float] | None:
|
||||
"""노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다.
|
||||
|
||||
측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기
|
||||
콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블
|
||||
y 고정용). 실패 시 None(측점 자체 범위 폴백).
|
||||
"""
|
||||
half = half_height = 0.0
|
||||
found = False
|
||||
try:
|
||||
for path in _cross_files(longitudinal_path, longitudinal):
|
||||
source = _read_json(path)
|
||||
min_e: float | None = None
|
||||
max_e: float | None = None
|
||||
for sample in source.get("samples", []):
|
||||
if not isinstance(sample, dict) or not sample.get("valid", False):
|
||||
continue
|
||||
offset = sample.get("offset_m")
|
||||
elevation = sample.get("elevation_m")
|
||||
if isinstance(offset, (int, float)):
|
||||
half = max(half, abs(float(offset)))
|
||||
if isinstance(elevation, (int, float)):
|
||||
value = float(elevation)
|
||||
min_e = value if min_e is None else min(min_e, value)
|
||||
max_e = value if max_e is None else max(max_e, value)
|
||||
if min_e is not None and max_e is not None:
|
||||
half_height = max(half_height, (max_e - min_e) / 2.0)
|
||||
found = True
|
||||
except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
if not found:
|
||||
return None
|
||||
# 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영.
|
||||
return {"half": half or 12.0, "half_height": half_height + 1.5}
|
||||
|
||||
|
||||
def _cross_design_line(
|
||||
longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None
|
||||
) -> list[Any] | None:
|
||||
"""횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산."""
|
||||
if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list):
|
||||
return stored_design["design_line"]
|
||||
try:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
design = compute_cross_design(
|
||||
source.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))),
|
||||
ground_type="soil",
|
||||
section_mode="left_cut",
|
||||
)
|
||||
return design["design_line"]
|
||||
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _cross_section_input(
|
||||
longitudinal_path: Path,
|
||||
longitudinal: dict[str, Any],
|
||||
chainage: int,
|
||||
design: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
|
||||
path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
source = _read_json(path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
return {
|
||||
"chainage": chainage,
|
||||
"source": source,
|
||||
"design": design,
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
}
|
||||
|
||||
|
||||
def _read_cross_sheet(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_designs: dict[str, Any] | None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다."""
|
||||
designs = stored_designs if isinstance(stored_designs, dict) else {}
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path, designs)
|
||||
sheet = next((item for item in plan if item["id"] == drawing_id), None)
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
sections = [
|
||||
section
|
||||
for section in (
|
||||
_cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage))
|
||||
for chainage in sheet["chainages"]
|
||||
)
|
||||
if section is not None
|
||||
]
|
||||
label = f"횡단면도 {sheet['number']}장"
|
||||
return "cross", label, build_cross_sheet(sheet, sections), False, None
|
||||
|
||||
|
||||
def _read_drawing(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_design: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
|
||||
|
||||
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
|
||||
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은
|
||||
stored_design(없으면 기본값)에서 만든다.
|
||||
"""
|
||||
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
|
||||
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
|
||||
if manifest_entry.get("confirmed") and saved_path.is_file():
|
||||
saved = _read_json(saved_path)
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if _LONG_ID.fullmatch(drawing_id):
|
||||
source = _read_json(longitudinal_path)
|
||||
chunk = next(
|
||||
(item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None
|
||||
)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.")
|
||||
return (
|
||||
"longitudinal",
|
||||
str(chunk["label"]),
|
||||
build_longitudinal_drawing(source, drawing_id, chunk),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
|
||||
|
||||
if not _CROSS_ID.fullmatch(drawing_id):
|
||||
raise ValueError("올바르지 않은 도면 ID입니다.")
|
||||
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
|
||||
source = _read_json(path)
|
||||
label = str(source.get("label") or drawing_id)
|
||||
design_line = _cross_design_line(longitudinal_path, source, stored_design)
|
||||
quantity_table = _quantity_table(source)
|
||||
# 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식)
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
title = station_no_label(float(source.get("chainage_m", 0.0)), interval)
|
||||
# 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정.
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
frame = _route_cross_frame(longitudinal_path, longitudinal)
|
||||
return (
|
||||
"cross",
|
||||
label,
|
||||
build_cross_drawing(
|
||||
source,
|
||||
drawing_id,
|
||||
design_line,
|
||||
stored_design,
|
||||
quantity_table,
|
||||
title,
|
||||
design_elevation,
|
||||
frame,
|
||||
),
|
||||
False,
|
||||
quantity_table,
|
||||
)
|
||||
|
||||
|
||||
def _store_confirmed_drawing(
|
||||
project_root: Path,
|
||||
item: DesignDrawingItem,
|
||||
drawing: dict[str, Any],
|
||||
expected_ids: set[str],
|
||||
quantity_table: dict[str, Any] | None = None,
|
||||
quantity_tables: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
|
||||
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
|
||||
# CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다.
|
||||
drawing = {"format": DRAWING_FORMAT, **drawing}
|
||||
drawings_dir = _design_root(project_root) / "drawings"
|
||||
drawings_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = drawings_dir / f"{item.id}.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
manifest = _read_manifest(project_root)
|
||||
entry: dict[str, Any] = {
|
||||
"kind": item.kind,
|
||||
"label": item.label,
|
||||
"confirmed": True,
|
||||
"file": f"drawings/{item.id}.json",
|
||||
}
|
||||
if item.kind == "cross" and isinstance(quantity_table, dict):
|
||||
entry["quantity_table"] = quantity_table
|
||||
if isinstance(quantity_tables, dict) and quantity_tables:
|
||||
# 장 도면: 측점별 수량표를 그대로 보관한다(뒷단계 수량산출이 측점 단위).
|
||||
entry["quantity_tables"] = quantity_tables
|
||||
manifest["drawings"][item.id] = entry
|
||||
_write_manifest(project_root, manifest)
|
||||
confirmed_ids = {
|
||||
item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed")
|
||||
}
|
||||
return expected_ids.issubset(confirmed_ids)
|
||||
|
||||
|
||||
def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
|
||||
manifest = _read_manifest(project_root)
|
||||
entry = manifest["drawings"].get(drawing_id)
|
||||
if entry:
|
||||
entry["confirmed"] = False
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
|
||||
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
|
||||
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
|
||||
pool = get_db_pool()
|
||||
@@ -539,34 +157,6 @@ async def get_design_drawing(
|
||||
)
|
||||
|
||||
|
||||
def _recompute_confirmed_design(
|
||||
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
|
||||
|
||||
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
|
||||
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
|
||||
source = _read_json(cross_path)
|
||||
samples = source.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
ground_type=designation["ground_type"],
|
||||
section_mode=designation["section_mode"],
|
||||
ditch_side=designation.get("ditch_side"),
|
||||
)
|
||||
design["status"] = "confirmed"
|
||||
return design
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}/design-drawings/{drawing_id}/confirm",
|
||||
response_model=DesignDrawingConfirmResponse,
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""B07 도면 조립 지원 — 파일·매니페스트 읽기, 도면 목록·장 배치, 확정 저장.
|
||||
|
||||
라우터에서 HTTP 처리와 무관한 동기 헬퍼만 떼어 놓은 모듈이다(단일 파일 700줄 제한).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
build_cross_drawing,
|
||||
infer_station_interval,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
QUANTITY_VALUE_KEYS,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGE_DIR = "B07_DesignDetail"
|
||||
|
||||
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.")
|
||||
return payload
|
||||
|
||||
|
||||
def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]:
|
||||
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
||||
stations = longitudinal.get("stations")
|
||||
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
||||
files = sorted(cross_dir.glob("cross_*.json"))
|
||||
if valid_names:
|
||||
files = [path for path in files if path.name in valid_names]
|
||||
return files
|
||||
|
||||
|
||||
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
stations = longitudinal.get("stations", [])
|
||||
if not isinstance(stations, list):
|
||||
return {}
|
||||
return {
|
||||
round(float(station.get("chainage_m", 0))): station
|
||||
for station in stations
|
||||
if isinstance(station, dict)
|
||||
}
|
||||
|
||||
|
||||
def _design_root(project_root: Path) -> Path:
|
||||
return project_root / _STAGE_DIR
|
||||
|
||||
|
||||
def _read_manifest(project_root: Path) -> dict[str, Any]:
|
||||
path = _design_root(project_root) / "manifest.json"
|
||||
if not path.is_file():
|
||||
return {"drawings": {}}
|
||||
payload = _read_json(path)
|
||||
return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}}
|
||||
|
||||
|
||||
def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
stage_root = _design_root(project_root)
|
||||
stage_root.mkdir(parents=True, exist_ok=True)
|
||||
path = stage_root / "manifest.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int, dict[str, Any]] | None = None,
|
||||
) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
manifest_drawings = _read_manifest(project_root)["drawings"]
|
||||
# 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1).
|
||||
drawings = [
|
||||
DesignDrawingItem(
|
||||
id=str(chunk["id"]),
|
||||
kind="longitudinal",
|
||||
label=str(chunk["label"]),
|
||||
confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")),
|
||||
)
|
||||
for chunk in longitudinal_chunks(longitudinal)
|
||||
]
|
||||
for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs):
|
||||
chainages = sheet["chainages"]
|
||||
first = station_by_chainage.get(chainages[0], {})
|
||||
span = f"{chainages[0]}m"
|
||||
if len(chainages) > 1:
|
||||
span = f"{chainages[0]}~{chainages[-1]}m"
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=sheet["id"],
|
||||
kind="cross",
|
||||
label=f"{sheet['number']}장 ({span})",
|
||||
chainage_m=float(first.get("chainage_m", chainages[0])),
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
return drawings
|
||||
|
||||
|
||||
def _cross_sheet_plan(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int, dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
|
||||
|
||||
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
|
||||
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
blocks: list[tuple[int, float, float]] = []
|
||||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||||
match = _CROSS_ID.fullmatch(path.stem)
|
||||
if not match:
|
||||
continue
|
||||
chainage = int(match.group(1))
|
||||
source = _read_json(path)
|
||||
design = (designs or {}).get(chainage)
|
||||
design_line = _cross_design_line(longitudinal_path, source, design)
|
||||
width, height = section_block_size(source, design_line)
|
||||
blocks.append((chainage, width, height))
|
||||
return plan_cross_sheets(blocks)
|
||||
|
||||
|
||||
def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
|
||||
"""횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키).
|
||||
|
||||
center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값.
|
||||
나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로
|
||||
두어 CAD 테이블에서 사용자가 채운다.
|
||||
"""
|
||||
|
||||
def num(value: Any) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
ground = num(source.get("center_z"))
|
||||
planned = num(source.get("planned_elevation_m", source.get("design_elevation_m")))
|
||||
cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None
|
||||
fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None
|
||||
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
|
||||
|
||||
table: dict[str, float | None] = {
|
||||
"ground": ground,
|
||||
"planned": planned,
|
||||
"cut": cut,
|
||||
"fill": fill,
|
||||
}
|
||||
for key in QUANTITY_VALUE_KEYS:
|
||||
table.setdefault(key, num(quantities.get(key)))
|
||||
return table
|
||||
|
||||
|
||||
def _route_cross_frame(
|
||||
longitudinal_path: Path, longitudinal: dict[str, Any]
|
||||
) -> dict[str, float] | None:
|
||||
"""노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다.
|
||||
|
||||
측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기
|
||||
콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블
|
||||
y 고정용). 실패 시 None(측점 자체 범위 폴백).
|
||||
"""
|
||||
half = half_height = 0.0
|
||||
found = False
|
||||
try:
|
||||
for path in _cross_files(longitudinal_path, longitudinal):
|
||||
source = _read_json(path)
|
||||
min_e: float | None = None
|
||||
max_e: float | None = None
|
||||
for sample in source.get("samples", []):
|
||||
if not isinstance(sample, dict) or not sample.get("valid", False):
|
||||
continue
|
||||
offset = sample.get("offset_m")
|
||||
elevation = sample.get("elevation_m")
|
||||
if isinstance(offset, (int, float)):
|
||||
half = max(half, abs(float(offset)))
|
||||
if isinstance(elevation, (int, float)):
|
||||
value = float(elevation)
|
||||
min_e = value if min_e is None else min(min_e, value)
|
||||
max_e = value if max_e is None else max(max_e, value)
|
||||
if min_e is not None and max_e is not None:
|
||||
half_height = max(half_height, (max_e - min_e) / 2.0)
|
||||
found = True
|
||||
except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
if not found:
|
||||
return None
|
||||
# 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영.
|
||||
return {"half": half or 12.0, "half_height": half_height + 1.5}
|
||||
|
||||
|
||||
def _cross_design_line(
|
||||
longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None
|
||||
) -> list[Any] | None:
|
||||
"""횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산."""
|
||||
if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list):
|
||||
return stored_design["design_line"]
|
||||
try:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
design = compute_cross_design(
|
||||
source.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))),
|
||||
ground_type="soil",
|
||||
section_mode="left_cut",
|
||||
)
|
||||
return design["design_line"]
|
||||
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _cross_section_input(
|
||||
longitudinal_path: Path,
|
||||
longitudinal: dict[str, Any],
|
||||
chainage: int,
|
||||
design: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
|
||||
path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
source = _read_json(path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
return {
|
||||
"chainage": chainage,
|
||||
"source": source,
|
||||
"design": design,
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
}
|
||||
|
||||
|
||||
def _read_cross_sheet(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_designs: dict[str, Any] | None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다."""
|
||||
designs = stored_designs if isinstance(stored_designs, dict) else {}
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path, designs)
|
||||
sheet = next((item for item in plan if item["id"] == drawing_id), None)
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
sections = [
|
||||
section
|
||||
for section in (
|
||||
_cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage))
|
||||
for chainage in sheet["chainages"]
|
||||
)
|
||||
if section is not None
|
||||
]
|
||||
label = f"횡단면도 {sheet['number']}장"
|
||||
return "cross", label, build_cross_sheet(sheet, sections), False, None
|
||||
|
||||
|
||||
def _read_drawing(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_design: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
|
||||
|
||||
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
|
||||
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은
|
||||
stored_design(없으면 기본값)에서 만든다.
|
||||
"""
|
||||
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
|
||||
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
|
||||
if manifest_entry.get("confirmed") and saved_path.is_file():
|
||||
saved = _read_json(saved_path)
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if _LONG_ID.fullmatch(drawing_id):
|
||||
source = _read_json(longitudinal_path)
|
||||
chunk = next(
|
||||
(item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None
|
||||
)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.")
|
||||
return (
|
||||
"longitudinal",
|
||||
str(chunk["label"]),
|
||||
build_longitudinal_drawing(source, drawing_id, chunk),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
|
||||
|
||||
if not _CROSS_ID.fullmatch(drawing_id):
|
||||
raise ValueError("올바르지 않은 도면 ID입니다.")
|
||||
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
|
||||
source = _read_json(path)
|
||||
label = str(source.get("label") or drawing_id)
|
||||
design_line = _cross_design_line(longitudinal_path, source, stored_design)
|
||||
quantity_table = _quantity_table(source)
|
||||
# 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식)
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
title = station_no_label(float(source.get("chainage_m", 0.0)), interval)
|
||||
# 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정.
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
frame = _route_cross_frame(longitudinal_path, longitudinal)
|
||||
return (
|
||||
"cross",
|
||||
label,
|
||||
build_cross_drawing(
|
||||
source,
|
||||
drawing_id,
|
||||
design_line,
|
||||
stored_design,
|
||||
quantity_table,
|
||||
title,
|
||||
design_elevation,
|
||||
frame,
|
||||
),
|
||||
False,
|
||||
quantity_table,
|
||||
)
|
||||
|
||||
|
||||
def _store_confirmed_drawing(
|
||||
project_root: Path,
|
||||
item: DesignDrawingItem,
|
||||
drawing: dict[str, Any],
|
||||
expected_ids: set[str],
|
||||
quantity_table: dict[str, Any] | None = None,
|
||||
quantity_tables: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
|
||||
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
|
||||
# CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다.
|
||||
drawing = {"format": DRAWING_FORMAT, **drawing}
|
||||
drawings_dir = _design_root(project_root) / "drawings"
|
||||
drawings_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = drawings_dir / f"{item.id}.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
manifest = _read_manifest(project_root)
|
||||
entry: dict[str, Any] = {
|
||||
"kind": item.kind,
|
||||
"label": item.label,
|
||||
"confirmed": True,
|
||||
"file": f"drawings/{item.id}.json",
|
||||
}
|
||||
if item.kind == "cross" and isinstance(quantity_table, dict):
|
||||
entry["quantity_table"] = quantity_table
|
||||
if isinstance(quantity_tables, dict) and quantity_tables:
|
||||
# 장 도면: 측점별 수량표를 그대로 보관한다(뒷단계 수량산출이 측점 단위).
|
||||
entry["quantity_tables"] = quantity_tables
|
||||
manifest["drawings"][item.id] = entry
|
||||
_write_manifest(project_root, manifest)
|
||||
confirmed_ids = {
|
||||
item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed")
|
||||
}
|
||||
return expected_ids.issubset(confirmed_ids)
|
||||
|
||||
|
||||
def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
|
||||
manifest = _read_manifest(project_root)
|
||||
entry = manifest["drawings"].get(drawing_id)
|
||||
if entry:
|
||||
entry["confirmed"] = False
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
|
||||
def _recompute_confirmed_design(
|
||||
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
|
||||
|
||||
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
|
||||
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
|
||||
source = _read_json(cross_path)
|
||||
samples = source.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
ground_type=designation["ground_type"],
|
||||
section_mode=designation["section_mode"],
|
||||
ditch_side=designation.get("ditch_side"),
|
||||
)
|
||||
design["status"] = "confirmed"
|
||||
return design
|
||||
@@ -74,40 +74,9 @@ const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
|
||||
/** 측점 간격을 연속 chainage 차이의 최빈값으로 추정한다 (B06 그래프와 동일 방식). */
|
||||
function inferStationInterval(chainages: number[]): number {
|
||||
const counts = new Map<number, number>();
|
||||
const sorted = [...chainages].sort((a, b) => a - b);
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
const difference = sorted[index] - sorted[index - 1];
|
||||
if (difference <= 0) continue;
|
||||
const rounded = Math.round(difference * 10) / 10;
|
||||
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
|
||||
}
|
||||
return (
|
||||
[...counts.entries()].sort(
|
||||
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
|
||||
)[0]?.[0] ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
/** 측점 번호+나머지 표기 (B06 그래프 영역 횡단도 라벨과 동일 형식, 예: "2+0.0"). */
|
||||
function stationLabel(chainage: number, interval: number): string {
|
||||
const safeInterval = interval > 0 ? interval : 1;
|
||||
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
|
||||
let remainder = chainage - stationNumber * safeInterval;
|
||||
if (Math.abs(remainder) < 0.05) remainder = 0;
|
||||
if (remainder >= safeInterval - 0.05) {
|
||||
stationNumber += 1;
|
||||
remainder = 0;
|
||||
}
|
||||
return `${stationNumber}+${remainder.toFixed(1)}`;
|
||||
}
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
function buildDrawingSidePanel(
|
||||
drawings: DesignDrawingItem[],
|
||||
stationInterval: number,
|
||||
onSelect: (drawing: DesignDrawingItem) => void,
|
||||
errorMessage?: string,
|
||||
): HTMLDivElement {
|
||||
@@ -152,10 +121,8 @@ function buildDrawingSidePanel(
|
||||
button.dataset.confirmed = String(drawing.confirmed);
|
||||
const name = document.createElement("span");
|
||||
name.className = "b07-drawing-button__name";
|
||||
name.textContent =
|
||||
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label;
|
||||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||||
name.textContent = drawing.label;
|
||||
button.append(name);
|
||||
button.addEventListener("click", () => onSelect(drawing));
|
||||
section.append(button);
|
||||
@@ -294,11 +261,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
||||
cadHost.append(frame, license);
|
||||
|
||||
const crossChainages = drawings
|
||||
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
|
||||
.map((item) => item.chainage_m as number);
|
||||
const stationInterval = inferStationInterval(crossChainages);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined;
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
@@ -318,10 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const title =
|
||||
typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label;
|
||||
const title = drawing.label;
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
|
||||
};
|
||||
|
||||
@@ -349,10 +308,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
index: number,
|
||||
): DesignMeta => ({
|
||||
kind: drawing.kind,
|
||||
title:
|
||||
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label,
|
||||
title: drawing.label,
|
||||
info: drawing.kind === "cross" ? drawing.label : "",
|
||||
confirmed: response.confirmed,
|
||||
quantityTable: response.quantity_table ?? null,
|
||||
@@ -433,11 +389,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (button) button.dataset.confirmed = "true";
|
||||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
||||
if (currentDrawing.kind === "cross") {
|
||||
const infoTitle =
|
||||
typeof currentDrawing.chainage_m === "number"
|
||||
? stationLabel(currentDrawing.chainage_m, stationInterval)
|
||||
: currentDrawing.label;
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null));
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(currentDrawing.label, result.design ?? null),
|
||||
);
|
||||
}
|
||||
showToast("현재 도면을 확정하고 저장했습니다.", "success");
|
||||
if (result.all_confirmed) {
|
||||
@@ -502,12 +456,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
stationInterval,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
);
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
|
||||
Reference in New Issue
Block a user