auto: 2026-08-29 18:42 (EOMSANGDON-HOME)

This commit is contained in:
2026-08-29 18:42:34 +09:00
parent f7ec239625
commit 54262f9eda
4 changed files with 460 additions and 480 deletions
@@ -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 = [
+8 -418
View File
@@ -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
+8 -59
View File
@@ -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로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로