From f4f56069b08dc0d805fa0da6a8e68410d25926da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 3 Aug 2026 19:54:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05/B06):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=ED=9A=A1=EB=8B=A8=20=EA=B8=B0=EC=A4=80=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=C2=B7=EC=9E=90=EB=8F=99=EC=84=A0=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=C2=B7=EA=B3=84=ED=9A=8D=EC=84=A0=20R=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=99=B8=209=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 피드백 반영(2026-08-03 2차). 유토곡선 일원화 - B05 유토곡선을 B06과 같은 계산(computeMassHaulSeries: 횡단 정식 + 종단 개략 비교)으로 전환. 상세 조회가 미지정 측점에 기본 설계를 즉석 계산해 얹으므로 B05 진입 시점에 이미 정식 곡선 재료가 있다. 표시 토글 sessionStorage 키와 balloon 위치 scope를 B06과 공유해 두 화면이 같은 그림을 유지한다. - B05 전용 개략 엔진(computeLongitudinalMassHaul) 삭제. - 유토곡선 펼침 시 종단 그래프:곡선 세로 1:1 분할. 사토장·토취장 자동 선정 삭제 - 임도에는 토취장이 없고 부족분은 설계자가 계획선을 고쳐 맞춘다는 실무 판단에 따라 4옵션(비용/지형안정성/계곡부/임내공간) 엔진·API·UI·config·저장 훅 전부 제거. massHaulPayload/createMassHaulLegend 시그니처 원복. 기본 지반 변경 - 기본 설계 프리뷰·확정 기본값을 토사에서 리핑암 + 예상 암반 경계 0.5m로 변경. 산지 절토는 표토 아래 암이 일반적이라 전량 토사 가정은 물량이 낙관적이다. 발파암은 B06에서 측점별 수정. 계획선 R·선 연결 (배관 구조물 자리) - 계획선 샘플이 고정 격자로만 평가되어 격자 사이 변화점(배관 측점 승격분)의 모서리를 잘라먹던 결함 수정 — 샘플 집합에 PVI·BVC·EVC를 합집합으로 포함 (프론트 buildAlignment + 서버 build_alignment 동일 규칙). 면적 가중치도 합쳐진 격자로 재계산. 37.3m 변화점 + R=150 수치 검증 통과. 표시 개선 - EP(종점) 잔량 라벨: 곡선 끝점에 "EP {누가토량}㎥" 불투명 판(B05·B06 공통). - 유토곡선 0선을 붉은 굵은 실선(2.5px)으로, 0 눈금값도 적색. - 3D 뷰 [측점 가로선] 우측 [측점 라벨] 토글 신설(기본 꺼짐) — 구조물 측점만 "측점번호 구조물명" 스프라이트 표시. typecheck·vite build·ruff·B03 테스트 통과. 실서버 스모크로 리핑암 기본 프리뷰와 disposal-sites 제거 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 66 --- .../B05_wf2_Route_Engine_Disposal.py | 432 ------------------ .../B05_wf2_Route_Engine_Grade_Alignment.py | 23 +- B05_wf2_Route/B05_wf2_Route_Router.py | 77 +--- B05_wf2_Route/B05_wf2_Route_Router_Confirm.py | 23 - B05_wf2_Route/B05_wf2_Route_Schema.py | 45 -- B05_wf2_Route/B05_wf2_Route_UI_Markers.ts | 57 ++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 27 +- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 3 + .../B05_wf2_Route_UI_Profile_Alignment.ts | 40 +- .../B05_wf2_Route_UI_Profile_Disposal.ts | 368 --------------- .../B05_wf2_Route_UI_Profile_MassHaul.ts | 164 +++---- .../B05_wf2_Route_UI_Profile_Panel.ts | 31 +- .../B05_wf2_Route_UI_Style_Disposal.css | 121 ----- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 3 + .../B06_wf3_ProfileCross_Api_Fetch.ts | 6 - .../B06_wf3_ProfileCross_Router.py | 15 +- .../B06_wf3_ProfileCross_UI_Page.ts | 6 +- .../B06_wf3_ProfileCross_UI_Section_View.ts | 4 - common_util/common_util_mass_haul.css | 38 +- common_util/common_util_mass_haul.ts | 59 --- common_util/common_util_mass_haul_sites.ts | 126 ----- common_util/common_util_mass_haul_view.ts | 45 +- config/config_system.py | 40 -- ui_template/ui_template_locale_b2.ts | 1 + 25 files changed, 286 insertions(+), 1534 deletions(-) delete mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py delete mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts delete mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css delete mode 100644 common_util/common_util_mass_haul_sites.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 7b71ae05..427818da 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -5,7 +5,6 @@ * 백엔드 계약 (B05_wf2_Route_Router.py): * POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록 * POST /api/projects/{project_id}/route/confirm → 최신 경로 확정 - * POST /api/projects/{project_id}/route/disposal-sites → 사토장·토취장 후보지 선정 * * 규칙: * - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지). @@ -243,8 +242,6 @@ export interface RouteConfirmRequest { irregular_stations?: Array<{ chainage_m: number; structure: string }>; /** 측점 상단측(=측구 방향) 사용자 변경분 — 3D 램프 클릭으로 지정. */ uphill_overrides?: Array<{ chainage_m: number; side: "left" | "right" }>; - /** 사토장·토취장 선정 결과. null이면 종단 정본에 저장된 이전 선정을 지운다. */ - disposal_sites?: Record | null; } /** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */ @@ -300,66 +297,3 @@ export function clearRouteLatestCache(projectId: string): void { /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ } } - -/** 사토장·토취장 후보지 선정 기준. 프론트 토글 4개와 1:1로 대응한다. */ -export type DisposalOption = "cost" | "stability" | "valley" | "clearing"; - -/** 유토곡선이 낸 잉여(사토)/부족(토취) 구간 하나. */ -export interface DisposalZone { - from_m: number; - to_m: number; - volume_m3: number; -} - -/** 후보지 하나. 사용자 정의로 덮어쓸 수 있는 값은 `chainage_m`·`assigned_m3`다. */ -export interface DisposalSite { - id: string; - kind: "spoil" | "borrow"; - chainage_m: number; - offset_m: number; - side: "left" | "right"; - x: number; - y: number; - elevation_m: number; - capacity_m3: number; - assigned_m3: number; - haul_distance_m: number; - mean_slope: number; - max_slope: number; - /** 노면 대비 낙차(m). 양수면 부지가 노면보다 낮아 하향 운반이다. */ - drop_m: number; - valley_distance_m: number | null; - canopy_m: number | null; - score: number; -} - -export interface DisposalSitesResponse { - status: string; - project_id: string; - route_id: number; - option: DisposalOption; - spoil: DisposalSite[]; - borrow: DisposalSite[]; - route_length_m: number; - /** 후보 부지가 모자라 담지 못한 토량(㎥). 0이 아니면 화면이 경고로 알린다. */ - unassigned_spoil_m3: number; - unassigned_borrow_m3: number; -} - -/** 선정 기준 하나로 사토장·토취장 후보지를 계산한다(서버 저장 없음, 조회 전용). */ -export async function computeDisposalSites( - projectId: string, - routeId: number, - option: DisposalOption, - volumes: { - surplus_m3: number; - shortage_m3: number; - surplus_zones: DisposalZone[]; - shortage_zones: DisposalZone[]; - }, -): Promise { - return requestJson(`/projects/${projectId}/route/disposal-sites`, { - method: "POST", - body: JSON.stringify({ route_id: routeId, option, ...volumes }), - }); -} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py b/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py deleted file mode 100644 index 380db8c6..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Disposal.py +++ /dev/null @@ -1,432 +0,0 @@ -"""사토장·토취장 후보지 자동 선정 엔진 (B05 계획 유토곡선). - -유토곡선이 낸 잉여(사토)·부족(토취) 토량을 노선 주변 어디서 처리할지 고른다. -DEM 격자에서 후보 부지를 추리고 옵션별 점수로 정렬해 상위 몇 곳을 돌려준다. - -── 왜 노선 corridor 안에서만 찾는가 ───────────────────────────────── -사토·토취는 운반비가 곧 비용이라, 노선에서 멀어지면 실무적으로 선택지가 아니다. -탐색 범위를 `DISPOSAL_SITE_CRITERIA["corridor_half_width_m"]`로 묶어 계산량을 노선 -연장에 비례하게 유지한다(2026-08-03 사용자 확정). - -── 선정 기준 4가지 ───────────────────────────────────────────────── -실무 선정 기준(토목시공학 계열 정리, 2026-08-03 조사)을 격자 판정으로 옮겼다. - - cost 운반거리 최소 + 하향 운반 우대. 트럭이 흙을 싣고 오르막을 타지 않게 한다. - stability 산사태·붕괴 위험이 없는 평탄지·완경사지. 계곡부·집수 상부는 감점. - valley 계곡부 매립 — 배관(암거) 지점과 연계해 주변부를 쌓아 올리는 실무 방식. - clearing 임내 공간 — 라이다 식생 높이가 낮은 공터·벌채지를 우선. - -토취장은 파내는 쪽이라 사토장보다 경사 상한이 관대하다(`borrow_max_ground_slope`). -**토질 판정은 아직 없다** — 지반유형(토사/리핑암/발파암)은 B06 횡단 설계가 만드는 값이고 -B05 계획 단계에는 노선 위 측점의 지반유형조차 확정되지 않았다. 지금은 경사만 보고 거르며, -발파암 배제는 B06 확정 지반유형이 붙은 뒤에 걸어야 한다. - -기준값 정의처는 `config_system.DISPOSAL_SITE_CRITERIA` 한 곳뿐이다. -""" - -from __future__ import annotations - -import logging -import math -from pathlib import Path -from typing import Any - -import numpy as np - -from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import load_or_build_skeleton -from B05_wf2_Route.B05_wf2_Route_Engine_Solver import _load_or_build_cost_surface -from config.config_system import DISPOSAL_SITE_CRITERIA - -logger = logging.getLogger(__name__) - -_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" -_STRUCTURED_PATH = Path("B04_wf1_Surface") / "processed" / "structured.npz" - -#: 지원하는 선정 옵션. 프론트 토글 4개와 1:1로 대응한다. -DISPOSAL_OPTIONS = ("cost", "stability", "valley", "clearing") - - -class DisposalSiteError(ValueError): - """후보지 계산에 필요한 입력이 없거나 형식이 어긋날 때.""" - - -def _route_arrays(polyline: list[list[float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """노선 폴리라인을 (xy, z, 누가거리) 배열로 편다.""" - if len(polyline) < 2: - raise DisposalSiteError("노선 좌표가 2점 미만이라 후보지를 찾을 수 없습니다.") - points = np.asarray(polyline, dtype=float) - xy = points[:, :2] - z = points[:, 2] if points.shape[1] > 2 else np.zeros(len(points)) - steps = np.linalg.norm(np.diff(xy, axis=0), axis=1) - chainage = np.concatenate([[0.0], np.cumsum(steps)]) - return xy, z, chainage - - -def _resample_route( - xy: np.ndarray, z: np.ndarray, chainage: np.ndarray, spacing_m: float -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """노선을 일정 간격으로 다시 찍고 각 점의 진행 방향 단위벡터를 함께 낸다.""" - total = float(chainage[-1]) - count = max(int(total // max(spacing_m, 1.0)) + 1, 2) - targets = np.linspace(0.0, total, count) - sx = np.interp(targets, chainage, xy[:, 0]) - sy = np.interp(targets, chainage, xy[:, 1]) - sz = np.interp(targets, chainage, z) - samples = np.column_stack([sx, sy]) - tangent = np.gradient(samples, axis=0) - norm = np.linalg.norm(tangent, axis=1, keepdims=True) - tangent = np.divide(tangent, np.where(norm > 1e-9, norm, 1.0)) - return samples, sz, targets, tangent - - -def _valley_points(project_root: Path, filter_key: str, method: str, smooth: bool) -> np.ndarray: - """지형 스켈레톤의 계곡 축 좌표를 한 배열로 모은다. 없으면 빈 배열.""" - try: - skeleton = load_or_build_skeleton(project_root, filter_key, method, smooth) - except (FileNotFoundError, OSError, ValueError) as exc: - logger.warning("사토장 선정: 지형 스켈레톤을 열지 못했습니다 — %s", exc) - return np.empty((0, 2)) - collected: list[list[float]] = [] - for key in ("main_valley", "minor_valley"): - for line in skeleton.get(key) or []: - for point in line.get("polyline") or []: - if len(point) >= 2: - collected.append([float(point[0]), float(point[1])]) - return np.asarray(collected, dtype=float) if collected else np.empty((0, 2)) - - -def _canopy_grid(project_root: Path) -> tuple[dict[tuple[int, int], float], float] | None: - """라이다 점군에서 격자별 식생 높이(지표 대비 최고점)를 만든다. - - 구조화 점군(`structured.npz`)의 분류값을 쓰되, 분류가 없는 자료도 있으므로 - **격자 안 최고점 − 최저점**을 수고로 본다. 지표가 격자 안에서 크게 기울면 - 그 경사분이 수고에 섞이지만, 공터 판정은 "낮은 쪽"만 골라내면 되므로 - 보수적(=공터를 덜 잡는) 방향이라 문제되지 않는다. - """ - path = project_root / _STRUCTURED_PATH - if not path.is_file(): - return None - try: - with np.load(path, allow_pickle=False, mmap_mode="r") as data: - xyz = np.asarray(data["xyz"], dtype=float) - except (OSError, ValueError, KeyError) as exc: - logger.warning("사토장 선정: 구조화 점군을 읽지 못했습니다 — %s", exc) - return None - if xyz.size == 0: - return None - cell = float(DISPOSAL_SITE_CRITERIA["clearing_grid_m"]) - keys = np.floor(xyz[:, :2] / cell).astype(np.int64) - lowest: dict[tuple[int, int], float] = {} - highest: dict[tuple[int, int], float] = {} - for (col, row), height in zip(map(tuple, keys), xyz[:, 2]): - key = (int(col), int(row)) - if key not in lowest or height < lowest[key]: - lowest[key] = float(height) - if key not in highest or height > highest[key]: - highest[key] = float(height) - return {key: highest[key] - lowest[key] for key in lowest}, cell - - -class _Terrain: - """DEM 격자 접근 묶음 — 표고·경사를 좌표로 바로 묻는다.""" - - def __init__(self, project_root: Path, filter_key: str, method: str, smooth: bool) -> None: - x, y, z, valid, dz_dx, dz_dy, grid_res = _load_or_build_cost_surface( - project_root, project_root / _MODELS_SUBDIR, filter_key, method, smooth - ) - self.x = np.asarray(x, dtype=float) - self.y = np.asarray(y, dtype=float) - self.z = np.asarray(z, dtype=float) - self.valid = np.asarray(valid, dtype=bool) - self.slope = np.hypot(np.asarray(dz_dx, dtype=float), np.asarray(dz_dy, dtype=float)) - self.grid_res = float(grid_res) - - def index(self, px: float, py: float) -> tuple[int, int] | None: - """좌표를 격자 (row, col)로 옮긴다. 격자 밖이거나 무효면 None.""" - if self.x.size < 2 or self.y.size < 2: - return None - col = int(round((px - self.x[0]) / (self.x[1] - self.x[0]))) - row = int(round((py - self.y[0]) / (self.y[1] - self.y[0]))) - if not (0 <= row < self.z.shape[0] and 0 <= col < self.z.shape[1]): - return None - return (row, col) if bool(self.valid[row, col]) else None - - def patch_slope( - self, row: int, col: int, radius_cells: int, slope_limit: float - ) -> tuple[float, float, int]: - """후보 주변 격자의 (평균경사, 최대경사, **쓸 수 있는 칸 수**). - - 마지막 값은 경사 상한을 넘지 않는 유효 칸만 센 것이다 — 부지 용량은 반경 안 - 전체가 아니라 실제로 흙을 올릴 수 있는 넓이라야 후보마다 값이 갈린다. - """ - r0 = max(row - radius_cells, 0) - r1 = min(row + radius_cells + 1, self.z.shape[0]) - c0 = max(col - radius_cells, 0) - c1 = min(col + radius_cells + 1, self.z.shape[1]) - window = self.slope[r0:r1, c0:c1] - mask = self.valid[r0:r1, c0:c1] - if not mask.any(): - return (math.inf, math.inf, 0) - values = window[mask] - usable = int((mask & (window <= slope_limit)).sum()) - return (float(values.mean()), float(values.max()), usable) - - -def _zone_center(zones: list[dict[str, Any]]) -> float | None: - """토량 가중 평균 위치(m). 잉여/부족이 어디에 몰려 있는지의 대표값.""" - total = sum(float(zone.get("volume_m3") or 0.0) for zone in zones) - if total <= 0: - return None - weighted = sum( - float(zone.get("volume_m3") or 0.0) - * 0.5 - * (float(zone.get("from_m") or 0.0) + float(zone.get("to_m") or 0.0)) - for zone in zones - ) - return weighted / total - - -def _score( - option: str, - kind: str, - *, - haul_distance_m: float, - route_length_m: float, - mean_slope: float, - drop_m: float, - valley_distance_m: float | None, - canopy_m: float | None, -) -> float: - """0~1 점수. 옵션마다 무엇을 크게 보는지만 다르고 계산 재료는 공유한다.""" - near = 1.0 - min(haul_distance_m / max(route_length_m, 1.0), 1.0) - flat = 1.0 - min(mean_slope / max(float(DISPOSAL_SITE_CRITERIA["max_ground_slope"]), 1e-6), 1.0) - # 사토장은 노면보다 낮아야 트럭이 오르막을 타지 않는다(토취장은 반대로 무관). - downhill = 1.0 if kind == "spoil" and drop_m > 0 else 0.5 if drop_m > -2.0 else 0.0 - buffer_m = float(DISPOSAL_SITE_CRITERIA["valley_buffer_m"]) - if valley_distance_m is None: - away_from_valley = 0.5 - in_valley = 0.0 - else: - away_from_valley = min(valley_distance_m / max(buffer_m, 1e-6), 1.0) - in_valley = 1.0 - away_from_valley - open_ground = ( - 0.5 - if canopy_m is None - else 1.0 - - min(canopy_m / max(float(DISPOSAL_SITE_CRITERIA["clearing_max_canopy_m"]), 1e-6), 1.0) - ) - - if option == "cost": - return 0.6 * near + 0.25 * downhill + 0.15 * flat - if option == "stability": - return 0.5 * flat + 0.3 * away_from_valley + 0.2 * near - if option == "valley": - return 0.5 * in_valley + 0.3 * near + 0.2 * flat - return 0.5 * open_ground + 0.3 * near + 0.2 * flat - - -def _build_candidates( - *, - option: str, - kind: str, - terrain: _Terrain, - samples: np.ndarray, - sample_z: np.ndarray, - chainages: np.ndarray, - tangent: np.ndarray, - valley_xy: np.ndarray, - canopy: tuple[dict[tuple[int, int], float], float] | None, - focus_m: float | None, - route_length_m: float, -) -> list[dict[str, Any]]: - """corridor를 훑어 후보 부지를 만든다. 옵션·용도별 판정을 여기서 건다.""" - criteria = DISPOSAL_SITE_CRITERIA - half_width = float(criteria["corridor_half_width_m"]) - offset_step = float(criteria["offset_spacing_m"]) - radius_cells = max(int(round(float(criteria["flat_radius_m"]) / terrain.grid_res)), 1) - slope_limit = float( - criteria["borrow_max_ground_slope"] if kind == "borrow" else criteria["max_ground_slope"] - ) - depth_m = float(criteria["borrow_depth_m"] if kind == "borrow" else criteria["fill_depth_m"]) - buffer_m = float(criteria["valley_buffer_m"]) - cell_area = terrain.grid_res**2 - - offsets = [ - sign * step - for step in np.arange(offset_step, half_width + 1e-6, offset_step) - for sign in (1.0, -1.0) - ] - candidates: list[dict[str, Any]] = [] - for index in range(len(samples)): - # 진행방향에 수직인 법선으로 좌우를 벌린다(좌 = +, 우 = −). - normal = np.array([-tangent[index, 1], tangent[index, 0]]) - for offset in offsets: - px, py = samples[index] + normal * offset - cell = terrain.index(px, py) - if cell is None: - continue - row, col = cell - mean_slope, max_slope, usable_cells = terrain.patch_slope( - row, col, radius_cells, slope_limit - ) - if usable_cells <= 0: - continue - if not math.isfinite(mean_slope) or mean_slope > slope_limit: - continue - valley_distance = ( - float(np.min(np.linalg.norm(valley_xy - np.array([px, py]), axis=1))) - if valley_xy.size - else None - ) - # 안정성 기준은 계곡부를 빼고, 계곡부 매립 기준은 계곡부만 남긴다. - if option == "stability" and valley_distance is not None and valley_distance < buffer_m: - continue - if option == "valley" and (valley_distance is None or valley_distance > buffer_m): - continue - canopy_m: float | None = None - if canopy is not None: - grid, cell_size = canopy - canopy_m = grid.get((int(px // cell_size), int(py // cell_size))) - if option == "clearing" and ( - canopy_m is None or canopy_m > float(criteria["clearing_max_canopy_m"]) - ): - continue - - elevation = float(terrain.z[row, col]) - drop_m = float(sample_z[index]) - elevation - chainage = float(chainages[index]) - haul_distance = ( - abs(chainage - focus_m) + abs(offset) if focus_m is not None else abs(offset) - ) - candidates.append( - { - "kind": kind, - "chainage_m": round(chainage, 2), - "offset_m": round(float(offset), 2), - "side": "left" if offset > 0 else "right", - "x": round(float(px), 3), - "y": round(float(py), 3), - "elevation_m": round(elevation, 3), - # 용량 = 경사 상한을 넘지 않는 격자 면적 × 허용 두께. - "capacity_m3": round(usable_cells * cell_area * depth_m, 1), - "haul_distance_m": round(haul_distance, 1), - "mean_slope": round(mean_slope, 4), - "max_slope": round(max_slope, 4), - "drop_m": round(drop_m, 2), - "valley_distance_m": ( - None if valley_distance is None else round(valley_distance, 1) - ), - "canopy_m": None if canopy_m is None else round(canopy_m, 2), - "score": round( - _score( - option, - kind, - haul_distance_m=haul_distance, - route_length_m=route_length_m, - mean_slope=mean_slope, - drop_m=drop_m, - valley_distance_m=valley_distance, - canopy_m=canopy_m, - ), - 4, - ), - } - ) - return candidates - - -def _pick(candidates: list[dict[str, Any]], demand_m3: float) -> list[dict[str, Any]]: - """점수 순으로 고르되 서로 겹치지 않게 띄우고, 필요한 토량이 찰 때까지만 담는다.""" - criteria = DISPOSAL_SITE_CRITERIA - spacing = float(criteria["flat_radius_m"]) * 2.0 - limit = int(criteria["max_candidates"]) - chosen: list[dict[str, Any]] = [] - remaining = max(demand_m3, 0.0) - for entry in sorted(candidates, key=lambda item: -item["score"]): - if len(chosen) >= limit: - break - if any( - abs(entry["chainage_m"] - picked["chainage_m"]) < spacing - and entry["side"] == picked["side"] - for picked in chosen - ): - continue - assigned = min(entry["capacity_m3"], remaining) if remaining > 0 else 0.0 - chosen.append( - {**entry, "id": f"{entry['kind']}-{len(chosen) + 1}", "assigned_m3": round(assigned, 1)} - ) - remaining -= assigned - if remaining <= 0: - break - return chosen - - -def compute_disposal_sites( - project_root: Path, - route_polyline: list[list[float]], - *, - option: str, - filter_key: str, - method: str, - smooth: bool, - surplus_m3: float, - shortage_m3: float, - surplus_zones: list[dict[str, Any]] | None = None, - shortage_zones: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - """옵션 하나에 대한 사토장·토취장 후보지를 계산한다. - - 잉여가 없으면 사토장을, 부족이 없으면 토취장을 빈 목록으로 돌려준다 — - 필요 없는 부지를 억지로 만들면 사용자가 잘못된 선택을 하게 된다. - """ - if option not in DISPOSAL_OPTIONS: - raise DisposalSiteError(f"알 수 없는 선정 기준입니다: {option}") - - xy, z, chainage = _route_arrays(route_polyline) - samples, sample_z, chainages, tangent = _resample_route( - xy, z, chainage, float(DISPOSAL_SITE_CRITERIA["along_spacing_m"]) - ) - route_length_m = float(chainage[-1]) - terrain = _Terrain(project_root, filter_key, method, smooth) - valley_xy = ( - _valley_points(project_root, filter_key, method, smooth) - if option in ("stability", "valley") - else np.empty((0, 2)) - ) - canopy = _canopy_grid(project_root) if option == "clearing" else None - - result: dict[str, Any] = { - "option": option, - "spoil": [], - "borrow": [], - # 후보가 모자라 어느 부지에도 담지 못한 토량. 0이 아니면 화면이 그대로 알려야 한다 — - # 조용히 빠지면 사용자는 계획이 완성된 줄 알고 다음 단계로 넘어간다. - "unassigned_spoil_m3": 0.0, - "unassigned_borrow_m3": 0.0, - } - plans = ( - ("spoil", surplus_m3, _zone_center(surplus_zones or [])), - ("borrow", shortage_m3, _zone_center(shortage_zones or [])), - ) - for kind, demand, focus in plans: - if demand <= 0: - continue - candidates = _build_candidates( - option=option, - kind=kind, - terrain=terrain, - samples=samples, - sample_z=sample_z, - chainages=chainages, - tangent=tangent, - valley_xy=valley_xy, - canopy=canopy, - focus_m=focus, - route_length_m=route_length_m, - ) - picked = _pick(candidates, demand) - result[kind] = picked - assigned = sum(float(site["assigned_m3"]) for site in picked) - result[f"unassigned_{kind}_m3"] = round(max(demand - assigned, 0.0), 1) - result["route_length_m"] = round(route_length_m, 2) - return result diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py index c2231797..b1372155 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py @@ -364,9 +364,20 @@ def build_alignment( pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii) segments = _segments(pvi_s, pvi_z) - plan = evaluate(pvi_s, pvi_z, curves, chainage) - difference = plan - ground - weights = _trapezoid_weights(chainage) + # 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면 + # 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를 + # 정본 계획선이 잘라먹어, 종단곡선 R이 보이지 않고 B06 계획고·B07 CAD 계획선이 그 + # 지점에서 어긋난다(2026-08-03 사용자 보고). 프론트(buildAlignment)와 같은 규칙이다. + extra_points = [pvi_s] + [ + np.array([curve["bvc_m"], curve["chainage_m"], curve["evc_m"]]) for curve in curves + ] + merged = np.concatenate([chainage, *extra_points]) + merged = merged[(merged >= chainage[0] - 1e-6) & (merged <= chainage[-1] + 1e-6)] + sample_s = np.unique(np.round(merged, 6)) + sample_ground = np.interp(sample_s, chainage, ground) + plan = evaluate(pvi_s, pvi_z, curves, sample_s) + difference = plan - sample_ground + weights = _trapezoid_weights(sample_s) cut_area = float(weights[difference < 0] @ -difference[difference < 0]) fill_area = float(weights[difference > 0] @ difference[difference > 0]) @@ -451,12 +462,12 @@ def build_alignment( "stations": _station_rows(stations, pvi_s, pvi_z, curves, chainage, ground), "samples": [ { - "chainage_m": round(float(chainage[index]), 6), + "chainage_m": round(float(sample_s[index]), 6), "elevation_m": round(float(plan[index]), 6), - "ground_elevation_m": round(float(ground[index]), 6), + "ground_elevation_m": round(float(sample_ground[index]), 6), "difference_m": round(float(difference[index]), 6), } - for index in range(len(chainage)) + for index in range(len(sample_s)) ], "balance": { "cut_area_m2": round(cut_area, 6), diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 654cf9c7..0efb30c9 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -14,10 +14,9 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design -from B05_wf2_Route.B05_wf2_Route_Engine_Disposal import DisposalSiteError, compute_disposal_sites from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile -from B05_wf2_Route.B05_wf2_Route_Engine_Sections import _load_route_polyline, run_section_generation +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Repository import ( confirm_route, @@ -31,15 +30,12 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from B05_wf2_Route.B05_wf2_Route_Router_Confirm import ( _append_irregular_cross_sections, - _merge_disposal_sites_into_longitudinal, _merge_uphill_overrides_into_longitudinal, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( GRADE_PERCENT_FIELDS, ContourIntervalUpdateRequest, ContourIntervalUpdateResponse, - DisposalSitesRequest, - DisposalSitesResponse, ProfileAlignmentSaveRequest, ProfileAlignmentSaveResponse, RouteConfirmRequest, @@ -467,60 +463,6 @@ async def save_profile_alignment( ) -@router.post("/{project_id}/route/disposal-sites", response_model=DisposalSitesResponse) -async def compute_route_disposal_sites( - project_id: UUID, request: DisposalSitesRequest -) -> DisposalSitesResponse | JSONResponse: - """선정 기준 하나로 사토장·토취장 후보지를 계산한다(저장 없음, 조회 전용).""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - latest = await get_latest_route(connection, project_id) - if not latest or int(latest["id"]) != request.route_id: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) - project_root = Path(resolve_stored_project_path(stored_path)) - polyline = await asyncio.to_thread( - _load_route_polyline, project_root, str(latest["route_data_path"]) - ) - # DEM 격자 훑기는 이벤트 루프를 막지 않도록 별도 스레드에서 실행한다. - sites = await asyncio.to_thread( - compute_disposal_sites, - project_root, - polyline, - option=request.option, - filter_key=str(surface_params["source_filter"]), - method=str(surface_params["method"]), - smooth=bool(surface_params["smooth"]), - surplus_m3=request.surplus_m3, - shortage_m3=request.shortage_m3, - surplus_zones=[zone.model_dump() for zone in request.surplus_zones], - shortage_zones=[zone.model_dump() for zone in request.shortage_zones], - ) - return DisposalSitesResponse( - project_id=str(project_id), - route_id=request.route_id, - option=sites["option"], - spoil=sites["spoil"], - borrow=sites["borrow"], - route_length_m=sites["route_length_m"], - unassigned_spoil_m3=sites["unassigned_spoil_m3"], - unassigned_borrow_m3=sites["unassigned_borrow_m3"], - ) - except (DisposalSiteError, FileNotFoundError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B05 사토장·토취장 선정 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "후보지 계산 중 오류가 발생했습니다."}, - ) - - @router.get("/{project_id}/route/latest", response_model=RouteLatestResponse) async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse: """최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다.""" @@ -582,23 +524,6 @@ async def confirm_latest_route( project_id, latest["id"], ) - # 사토장·토취장 선정 결과를 종단 정본에 심는다 — 비치명적. - try: - stored_path = await get_project_storage_relative_path(connection, project_id) - longitudinal = await get_longitudinal_section(connection, project_id, latest["id"]) - if longitudinal: - await asyncio.to_thread( - _merge_disposal_sites_into_longitudinal, - Path(resolve_stored_project_path(stored_path)), - str(longitudinal["longitudinal_file_path"]), - request.disposal_sites, - ) - except Exception: - logger.exception( - "B05 사토장·토취장 저장 실패 (경로 확정은 진행): project_id=%s route_id=%s", - project_id, - latest["id"], - ) # 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적. if request.uphill_overrides: try: diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py index 291e015d..824ecfbe 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py @@ -72,29 +72,6 @@ def _merge_uphill_overrides_into_longitudinal( atomic_write_json(path, data) -def _merge_disposal_sites_into_longitudinal( - project_root: Path, longitudinal_file_path: str, disposal: dict[str, Any] | None -) -> None: - """사토장·토취장 선정 결과를 종단 정본 파일에 심는다(확정 = 영구저장 시점). - - B06 정식 유토곡선이 이 값을 읽어 운반거리를 다시 재고, B08 내역서가 운반 항목으로 - 받는다. `None`이면(기준 미선택) 기존에 저장된 값을 **지운다** — 사용자가 선정을 - 풀었는데 옛 부지가 남아 있으면 다음 단계가 없는 계획을 있다고 읽는다. - """ - root = project_root.resolve() - path = (root / longitudinal_file_path).resolve() - if root not in path.parents or not path.is_file(): - return - data = json.loads(path.read_text(encoding="utf-8")) - if disposal: - data["disposal_sites"] = disposal - elif "disposal_sites" in data: - data.pop("disposal_sites") - else: - return - atomic_write_json(path, data) - - def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> None: diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index a2605dfe..267a66fb 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -258,9 +258,6 @@ class RouteConfirmRequest(BaseModel): irregular_stations: list[IrregularStationInput] = Field(default_factory=list) # 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다. uphill_overrides: list[UphillSideOverride] = Field(default_factory=list) - # 사토장·토취장 선정 결과(기준 + 부지 목록). 화면 구조를 그대로 종단 정본에 심으므로 - # 서버는 형태를 강제하지 않는다 — 값의 의미는 B05 화면과 B06/B08 소비처가 함께 안다. - disposal_sites: dict[str, Any] | None = None def extra_stations(self) -> tuple[tuple[float, str], ...]: return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) @@ -292,45 +289,3 @@ class RouteLatestResponse(BaseModel): route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None - - -class DisposalZone(BaseModel): - """유토곡선이 낸 잉여(사토) 또는 부족(토취) 구간 하나.""" - - model_config = ConfigDict(extra="forbid") - - from_m: float = Field(ge=0) - to_m: float = Field(ge=0) - volume_m3: float = Field(ge=0) - - -class DisposalSitesRequest(BaseModel): - """사토장·토취장 후보지 자동 선정 요청. - - 토량은 프론트 유토곡선이 계산한 값을 그대로 받는다 — 같은 곡선을 서버에서 다시 - 적분하면 화면과 어긋날 여지가 생기고, 계획 단계에서는 곡선 자체가 계속 바뀐다. - """ - - model_config = ConfigDict(extra="forbid") - - route_id: int = Field(gt=0) - option: Literal["cost", "stability", "valley", "clearing"] - surplus_m3: float = Field(default=0.0, ge=0) - shortage_m3: float = Field(default=0.0, ge=0) - surplus_zones: list[DisposalZone] = Field(default_factory=list) - shortage_zones: list[DisposalZone] = Field(default_factory=list) - - -class DisposalSitesResponse(BaseModel): - """옵션 하나에 대한 후보지 목록. `spoil`이 사토장, `borrow`가 토취장.""" - - status: str = "ok" - project_id: str - route_id: int - option: str - spoil: list[dict[str, Any]] = Field(default_factory=list) - borrow: list[dict[str, Any]] = Field(default_factory=list) - route_length_m: float = 0.0 - # 후보 부지가 모자라 담지 못한 토량(㎥). 0이 아니면 화면이 경고로 알린다. - unassigned_spoil_m3: float = 0.0 - unassigned_borrow_m3: float = 0.0 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index d2bc8dbd..7eea5dfb 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -33,6 +33,12 @@ export interface SectionStationMarker { /** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */ uphill_side?: "left" | "right" | null; frame: { left_xy: [number, number] }; + /** 측점 종류 — 구조물(비정규) 측점만 3D 라벨을 단다(2026-08-03 사용자 지시). */ + kind?: string; + /** 측점번호 표기(`4+0.0`). 라벨 본문에 그대로 쓴다. */ + label?: string; + /** 구조물 이름(배관 등). 있으면 측점번호 뒤에 붙인다. */ + structure?: string; } // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. @@ -81,7 +87,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou const markerGroup = new THREE.Group(); const routeGroup = new THREE.Group(); const stationGroup = new THREE.Group(); - interactionGroup.add(markerGroup, stationGroup); + // 구조물 측점 라벨(스프라이트) 전용 — 측점선과 별개 토글이라 그룹을 나눈다. + const stationLabelGroup = new THREE.Group(); + stationLabelGroup.visible = false; + interactionGroup.add(markerGroup, stationGroup, stationLabelGroup); scene.add(interactionGroup, routeGroup); let points = emptyPoints(); let selectedId: string | null = null; @@ -240,8 +249,43 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou }); } + /** + * 측점 라벨 스프라이트. 카메라를 항상 바라보는 캔버스 텍스처라 줌·회전과 무관하게 읽힌다. + * 라벨은 **구조물(비정규) 측점에만** 단다 — 전 측점에 달면 글자가 겹쳐 도면을 못 읽는다. + */ + function stationLabelSprite(text: string, position: THREE.Vector3): THREE.Sprite { + const fontPx = 28; + const padPx = 10; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + context.font = `600 ${fontPx}px sans-serif`; + canvas.width = Math.ceil(context.measureText(text).width) + padPx * 2; + canvas.height = fontPx + padPx * 2; + // 캔버스 크기를 바꾸면 컨텍스트가 초기화되므로 폰트를 다시 지정해야 한다. + context.font = `600 ${fontPx}px sans-serif`; + context.fillStyle = "rgba(15, 23, 42, 0.72)"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#fef3c7"; + context.textBaseline = "middle"; + context.fillText(text, padPx, canvas.height / 2); + } + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }), + ); + const heightUnits = 3.2; + sprite.scale.set((canvas.width / canvas.height) * heightUnits, heightUnits, 1); + sprite.position.copy(position); + sprite.position.y += 3.4; + sprite.renderOrder = 10; + return sprite; + } + function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void { disposeGroup(stationGroup); + disposeGroup(stationLabelGroup); const bounds = getBounds(); if (!bounds || halfWidth <= 0) return; stations.forEach((station) => { @@ -269,6 +313,14 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). + // 구조물 측점 라벨: `측점번호 구조물명`. 일반 측점은 라벨을 달지 않는다. + if (station.kind === "irregular" && station.label) { + const text = station.structure?.trim() + ? `${station.label} ${station.structure.trim()}` + : station.label; + stationLabelGroup.add(stationLabelSprite(text, modelToScene(center, bounds))); + } + (["left", "right"] as const).forEach((side, endIndex) => { const active = station.uphill_side === side; const lamp = new THREE.Mesh( @@ -352,6 +404,9 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou setStationLinesVisible(visible: boolean) { stationGroup.visible = visible; }, + setStationLabelsVisible(visible: boolean) { + stationLabelGroup.visible = visible; + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c121950f..ed893af0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -168,6 +168,8 @@ function interpolateIrregularStations( chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, + // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. + structure: entry.structure, })); } @@ -301,6 +303,7 @@ export async function renderB05Route(root: HTMLElement): Promise { onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, onStationLinesVisible: viewer.setStationLinesVisible, + onStationLabelsVisible: viewer.setStationLabelsVisible, onView: viewer.setView, onResetView: () => viewer.setView("top"), onMovePoint: viewer.beginMoveSelected, @@ -594,8 +597,6 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: Number(chainage), side, })), - // 사토장·토취장 선정 결과 — 종단 정본에 심겨 B06 정식 유토곡선과 B08 내역서가 받는다. - disposal_sites: profilePanel.disposalPayload(), }); renderLatest(await loadLatest(true)); showToast("경로를 확정했습니다.", "success"); @@ -657,21 +658,13 @@ export async function renderB05Route(root: HTMLElement): Promise { crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, longSampleInterval: sectionContext.defaults.long_sample_interval_m, }); - // 계획 유토곡선용 설정 — 토량환산계수·운반장비 경계는 config_system 정의를 그대로 받아 쓰고, - // 노반폭은 표준횡단(토사)의 `노면폭 + 좌·우 길어깨`로 B06 엔진과 같은 정의를 맞춘다. - const standardSoil = sectionContext.standard_cross_section?.soil; - profilePanel.setEarthworkContext( - standardSoil - ? { - conversion: sectionContext.earthwork_conversion, - haulLimits: sectionContext.haul_equipment_limits, - roadbedWidthM: - standardSoil.road_width_m + - standardSoil.shoulder_left_m + - standardSoil.shoulder_right_m, - } - : null, - ); + // 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system + // 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지). + profilePanel.setEarthworkContext({ + conversion: sectionContext.earthwork_conversion, + haulLimits: sectionContext.haul_equipment_limits, + naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, + }); restorePanel(latestResponse); renderLatest(latestResponse); latest = latestResponse; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 92d6b979..b79187d6 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -52,6 +52,8 @@ interface PanelCallbacks { onContoursVisible: (visible: boolean) => void; onAxesVisible: (visible: boolean) => void; onStationLinesVisible: (visible: boolean) => void; + /** 구조물 측점 번호·이름 라벨 표시 토글(기본 꺼짐). */ + onStationLabelsVisible: (visible: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; onResetView: () => void; onMovePoint: () => void; @@ -159,6 +161,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { toggleButton("등고선", true, callbacks.onContoursVisible), toggleButton("축 표시", false, callbacks.onAxesVisible), toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), + toggleButton(L("B05_Route_Field_StationLabels"), false, callbacks.onStationLabelsVisible), ); const separator1 = document.createElement("span"); separator1.className = "b05-route__view-separator"; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts index cad30f50..a6068d64 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts @@ -321,17 +321,37 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m); }); - const samples: AlignmentSample[] = base.chainage.map((chainage, index) => { - const plan = evaluateAt(pviS, pviZ, curves, chainage); - return { - chainage_m: chainage, - elevation_m: plan, - ground_elevation_m: base.ground[index], - difference_m: plan - base.ground[index], - }; - }); + // 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 **합쳐서** 그린다. + // 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 + // 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼 + // 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다. + const sampleChainages = new Set(base.chainage.map((value) => Number(value.toFixed(3)))); + const first = base.chainage[0]; + const last = base.chainage[base.chainage.length - 1]; + for (const extra of [ + ...pviS, + ...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]), + ]) { + if (extra >= first - 1e-6 && extra <= last + 1e-6) { + sampleChainages.add(Number(extra.toFixed(3))); + } + } + const samples: AlignmentSample[] = [...sampleChainages] + .sort((a, b) => a - b) + .map((chainage) => { + const plan = evaluateAt(pviS, pviZ, curves, chainage); + const ground = interpolate(base.chainage, base.ground, chainage); + return { + chainage_m: chainage, + elevation_m: plan, + ground_elevation_m: ground, + difference_m: plan - ground, + }; + }); - const weights = trapezoidWeights(base.chainage); + // 면적 가중치도 **합쳐진 샘플 격자**로 계산해야 한다 — base.chainage로 두면 추가된 + // 변화점 샘플과 인덱스가 어긋나 절·성토 면적이 통째로 틀어진다. + const weights = trapezoidWeights(samples.map((sample) => sample.chainage_m)); let cutArea = 0; let fillArea = 0; samples.forEach((sample, index) => { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts deleted file mode 100644 index 3206750f..00000000 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* ============================================================================= - * B05_wf2_Route_UI_Profile_Disposal.ts - * 사토장·토취장 자동 선정 — 4옵션 토글과 결과 목록, 사용자 정의 편집. - * - * 토글은 계획 유토곡선 범례 줄의 [토량 분배]와 [도형 위치 초기화] **사이**에 구분기호와 - * 함께 들어간다(2026-08-03 사용자 지시). 하나를 켜면 나머지는 꺼지는 라디오 방식이다. - * - * ── 서버가 계산하는 것 / 화면이 계산하는 것 ────────────────────────── - * 후보 부지 탐색은 DEM 격자를 훑어야 해서 서버 몫이다(`/route/disposal-sites`). - * 화면은 유토곡선이 이미 낸 잔량(사토·토취 구간과 토량)을 그대로 실어 보낸다 — - * 같은 곡선을 서버에서 다시 적분하면 계획선을 끌 때마다 화면과 어긋난다. - * - * ── 사용자 정의 ───────────────────────────────────────────────────── - * 자동 선정값은 출발점일 뿐이라 위치(측점)와 용량을 손으로 고칠 수 있다. 고치면 그 부지에 - * "사용자 정의" 배지가 붙고 운반거리를 다시 잰다. [사용자 정의 삭제]로 자동값을 되돌린다. - * ========================================================================== */ - -import type { DisposalOption, DisposalSite, DisposalZone } from "./B05_wf2_Route_Api_Fetch"; -import { computeDisposalSites } from "./B05_wf2_Route_Api_Fetch"; -import type { HaulPlan } from "@util/common_util_mass_haul_balance"; -import { showToast } from "@ui/ui_template_elements"; - -/** 선택한 기준을 세션 동안 유지한다(패널 펼침·범례 토글과 같은 수명). */ -const OPTION_KEY = "b05-route-disposal-option"; - -const OPTION_LABELS: Array<{ key: DisposalOption; label: string; tip: string }> = [ - { key: "cost", label: "비용", tip: "운반거리가 짧고 트럭이 오르막을 타지 않는 자리를 고릅니다." }, - { - key: "stability", - label: "지형안정성", - tip: "평탄지·완경사지를 고르고 계곡부·하천 주변은 유실 위험으로 제외합니다.", - }, - { - key: "valley", - label: "계곡부", - tip: "배관(암거) 지점과 연계해 계곡부를 메우는 실무 방식으로 고릅니다.", - }, - { - key: "clearing", - label: "임내 공간", - tip: "라이다 식생 높이가 낮은 공터·벌채지를 우선합니다.", - }, -]; - -/** 화면이 들고 있는 부지 하나 — 서버 자동값 위에 사용자 수정분을 얹은 상태. */ -interface EditableSite extends DisposalSite { - /** 사용자가 위치나 용량을 고쳤는가. 배지 표시와 복원 대상 판정에 쓴다. */ - custom: boolean; - /** 자동 선정 당시 값 — [사용자 정의 삭제]가 여기로 되돌린다. */ - auto: { chainage_m: number; assigned_m3: number; haul_distance_m: number }; -} - -export interface RouteDisposalController { - /** 범례 줄에 끼워 넣을 토글 4개. */ - toggles: HTMLElement[]; - /** 선정 결과 목록 — 유토곡선 요약 막대 아래에 붙인다. */ - panel: HTMLElement; - setRoute(projectId: string, routeId: number | null): void; - /** 유토곡선이 다시 계산될 때마다 호출한다. 잔량이 바뀌면 결과를 다시 받는다. */ - update(plan: HaulPlan | null): void; - /** 확정 저장용 직렬화. 기준을 안 골랐으면 null. */ - payload(): Record | null; -} - -/** 잔량 목록을 서버가 받는 구간 형태로 옮긴다. */ -function zonesOf(plan: HaulPlan | null, kind: "spoil" | "borrow"): DisposalZone[] { - return (plan?.residuals ?? []) - .filter((residual) => residual.kind === kind) - .map((residual) => ({ - from_m: residual.from_m, - to_m: residual.to_m, - // 사토는 자연방토로 빠지는 몫을 뺀 나머지만 실어 내면 된다. - volume_m3: - kind === "spoil" - ? Math.max(residual.volume_m3 - residual.natural_m3, 0) - : residual.volume_m3, - })) - .filter((zone) => zone.volume_m3 > 0); -} - -function totalOf(zones: DisposalZone[]): number { - return zones.reduce((sum, zone) => sum + zone.volume_m3, 0); -} - -/** 잔량이 실질적으로 바뀌었는지 — 같은 값으로 서버를 다시 부르지 않기 위한 지문. */ -function signatureOf( - option: DisposalOption | null, - spoil: DisposalZone[], - borrow: DisposalZone[], -): string { - const round = (value: number): number => Math.round(value * 10) / 10; - const part = (zones: DisposalZone[]): string => - zones - .map((zone) => `${round(zone.from_m)}:${round(zone.to_m)}:${round(zone.volume_m3)}`) - .join(","); - return `${option ?? "-"}|${part(spoil)}|${part(borrow)}`; -} - -function metricRow(label: string, value: string): HTMLElement { - const item = document.createElement("span"); - item.className = "b05-disposal__metric"; - const caption = document.createElement("em"); - caption.textContent = label; - const amount = document.createElement("strong"); - amount.textContent = value; - item.append(caption, amount); - return item; -} - -export function createRouteDisposalPanel(): RouteDisposalController { - const panel = document.createElement("div"); - panel.className = "b05-disposal"; - panel.hidden = true; - - let projectId = ""; - let routeId: number | null = null; - let option: DisposalOption | null = - (sessionStorage.getItem(OPTION_KEY) as DisposalOption) || null; - let sites: EditableSite[] = []; - let signature = ""; - let pending = false; - let lastPlan: HaulPlan | null = null; - - const toggles = OPTION_LABELS.map(({ key, label, tip }) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b06-masshaul__legend-item b05-disposal__toggle"; - button.textContent = label; - button.title = tip; - button.addEventListener("click", () => select(key)); - return button; - }); - - function syncToggles(): void { - toggles.forEach((button, index) => { - const active = OPTION_LABELS[index].key === option; - button.classList.toggle("is-off", !active); - button.setAttribute("aria-pressed", String(active)); - // 하나를 고르면 나머지는 비활성 — 두 기준을 섞은 결과는 근거가 설명되지 않는다. - button.disabled = pending || (option !== null && !active); - }); - } - - function select(next: DisposalOption): void { - option = option === next ? null : next; - if (option) sessionStorage.setItem(OPTION_KEY, option); - else sessionStorage.removeItem(OPTION_KEY); - sites = []; - signature = ""; - syncToggles(); - void refresh(); - } - - async function refresh(): Promise { - if (!option || !routeId || !projectId) { - panel.hidden = true; - panel.replaceChildren(); - return; - } - const spoilZones = zonesOf(lastPlan, "spoil"); - const borrowZones = zonesOf(lastPlan, "borrow"); - const next = signatureOf(option, spoilZones, borrowZones); - if (next === signature) { - render(); - return; - } - if (!spoilZones.length && !borrowZones.length) { - signature = next; - sites = []; - render("절·성토가 균형을 이뤄 사토장·토취장이 필요하지 않습니다."); - return; - } - pending = true; - syncToggles(); - render("후보지를 계산하는 중…"); - try { - const response = await computeDisposalSites(projectId, routeId, option, { - surplus_m3: totalOf(spoilZones), - shortage_m3: totalOf(borrowZones), - surplus_zones: spoilZones, - shortage_zones: borrowZones, - }); - signature = next; - sites = [...response.spoil, ...response.borrow].map((site) => ({ - ...site, - custom: false, - auto: { - chainage_m: site.chainage_m, - assigned_m3: site.assigned_m3, - haul_distance_m: site.haul_distance_m, - }, - })); - // 후보가 모자라 남은 토량은 반드시 알린다 — 조용히 빠지면 계획이 완성된 줄 안다. - const leftover = - response.unassigned_spoil_m3 > 0 || response.unassigned_borrow_m3 > 0 - ? `후보지가 부족해 사토 ${response.unassigned_spoil_m3.toLocaleString()}㎥ · ` + - `토취 ${response.unassigned_borrow_m3.toLocaleString()}㎥가 배정되지 않았습니다.` - : undefined; - render(sites.length ? leftover : "조건에 맞는 후보지를 찾지 못했습니다."); - } catch (error) { - sites = []; - render(error instanceof Error ? error.message : "후보지를 계산하지 못했습니다."); - } finally { - pending = false; - syncToggles(); - } - } - - /** 사용자가 위치를 옮기면 운반거리는 잔량 구간 중심까지의 거리로 다시 잰다. */ - function recomputeHaul(site: EditableSite): void { - const zones = zonesOf(lastPlan, site.kind); - const total = totalOf(zones); - if (total <= 0) return; - const center = - zones.reduce((sum, zone) => sum + zone.volume_m3 * 0.5 * (zone.from_m + zone.to_m), 0) / - total; - site.haul_distance_m = - Math.round((Math.abs(site.chainage_m - center) + Math.abs(site.offset_m)) * 10) / 10; - } - - function editRow(site: EditableSite): HTMLElement { - const row = document.createElement("div"); - row.className = "b05-disposal__edit"; - const add = (label: string, value: number, apply: (next: number) => void): void => { - const wrap = document.createElement("label"); - wrap.className = "b05-disposal__field"; - const caption = document.createElement("span"); - caption.textContent = label; - const input = document.createElement("input"); - input.type = "number"; - input.step = "0.1"; - input.min = "0"; - input.value = String(value); - input.addEventListener("change", () => { - const parsed = Number(input.value); - if (!Number.isFinite(parsed) || parsed < 0) { - input.value = String(value); - showToast("0 이상의 숫자를 입력하세요.", "error"); - return; - } - apply(parsed); - site.custom = true; - recomputeHaul(site); - render(); - }); - wrap.append(caption, input); - row.append(wrap); - }; - add("측점(m)", site.chainage_m, (next) => { - site.chainage_m = next; - }); - add("용량(㎥)", site.assigned_m3, (next) => { - site.assigned_m3 = next; - }); - if (site.custom) { - const reset = document.createElement("button"); - reset.type = "button"; - reset.className = "b05-disposal__reset"; - reset.textContent = "사용자 정의 삭제"; - reset.title = "이 부지를 자동 선정값으로 되돌립니다."; - reset.addEventListener("click", () => { - site.chainage_m = site.auto.chainage_m; - site.assigned_m3 = site.auto.assigned_m3; - site.haul_distance_m = site.auto.haul_distance_m; - site.custom = false; - render(); - }); - row.append(reset); - } - return row; - } - - function siteCard(site: EditableSite): HTMLElement { - const card = document.createElement("div"); - card.className = `b05-disposal__site b05-disposal__site--${site.kind}`; - const head = document.createElement("div"); - head.className = "b05-disposal__head"; - const title = document.createElement("strong"); - title.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"} ${site.id.split("-")[1]}`; - head.append(title); - if (site.custom) { - const badge = document.createElement("span"); - badge.className = "b05-disposal__badge"; - badge.textContent = "사용자 정의"; - head.append(badge); - } - const metrics = document.createElement("div"); - metrics.className = "b05-disposal__metrics"; - metrics.append( - metricRow( - "위치", - `${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}${Math.abs(site.offset_m).toFixed(0)}m`, - ), - metricRow( - "배정", - `${site.assigned_m3.toLocaleString()}㎥ / 수용 ${site.capacity_m3.toLocaleString()}㎥`, - ), - metricRow("운반거리", `${site.haul_distance_m.toFixed(0)}m`), - metricRow("지반경사", `${(site.mean_slope * 100).toFixed(0)}%`), - ); - // 옵션마다 사용자가 확인해야 할 근거값이 다르다 — 고른 기준에 해당하는 것만 덧붙인다. - if (option === "cost") { - metrics.append( - metricRow("낙차", `${site.drop_m.toFixed(1)}m ${site.drop_m > 0 ? "(하향)" : "(상향)"}`), - ); - } else if (option === "stability" && site.valley_distance_m !== null) { - metrics.append(metricRow("계곡 이격", `${site.valley_distance_m.toFixed(0)}m`)); - } else if (option === "valley" && site.valley_distance_m !== null) { - metrics.append(metricRow("계곡 축까지", `${site.valley_distance_m.toFixed(0)}m`)); - } else if (option === "clearing" && site.canopy_m !== null) { - metrics.append(metricRow("수고", `${site.canopy_m.toFixed(1)}m`)); - } - card.append(head, metrics, editRow(site)); - return card; - } - - function render(note?: string): void { - panel.hidden = !option; - panel.replaceChildren(); - if (!option) return; - if (note) { - const message = document.createElement("span"); - message.className = "b05-disposal__note"; - message.textContent = note; - panel.append(message); - } - for (const site of sites) panel.append(siteCard(site)); - } - - syncToggles(); - - return { - toggles, - panel, - setRoute(nextProjectId, nextRouteId) { - if (projectId === nextProjectId && routeId === nextRouteId) return; - projectId = nextProjectId; - routeId = nextRouteId; - signature = ""; - sites = []; - void refresh(); - }, - update(plan) { - lastPlan = plan; - void refresh(); - }, - payload() { - if (!option) return null; - return { - option, - sites: sites.map((site) => ({ - id: site.id, - kind: site.kind, - chainage_m: site.chainage_m, - offset_m: site.offset_m, - side: site.side, - x: site.x, - y: site.y, - elevation_m: site.elevation_m, - capacity_m3: site.capacity_m3, - assigned_m3: site.assigned_m3, - haul_distance_m: site.haul_distance_m, - custom: site.custom, - })), - }; - }, - }; -} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index 6eba410c..c3b130e4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -1,26 +1,40 @@ /* ============================================================================= * B05_wf2_Route_UI_Profile_MassHaul.ts - * B05 계획 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널. + * B05 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널. * - * ── 왜 B05에 유토곡선이 있는가 ─────────────────────────────────────── - * 절·성토 균형은 [임도의 설계 및 시설기준] 2.다.(3)(나)가 **종단 시공계획고**를 대상으로 - * 정한다. 사토장·토취장을 어디에 둘지도 계획고를 다시 끌어야 정리되는 문제라, 계획선 - * 편집 정본·배수유역·배관 데이터가 모두 모여 있는 이 화면에서 곡선을 보며 잡는 것이 맞다. - * B06의 횡단 기준 곡선은 실측 단면적으로 낸 **정식 물량**이고, 이쪽은 계획용 개략값이다. + * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── + * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 + * 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로 + * (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴 + * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고, + * 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다. + * + * 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라 + * 한쪽에서 켠 곡선이 다른 쪽에서도 켜져 있어야 한다. 데이터 자체도 같은 상세 API와 + * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 + * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * * ── 배치 ───────────────────────────────────────────────────────── - * 펼치면 12행 도면 테이블 자리를 그대로 덮는다(2026-08-03 사용자 지시). 곡선 SVG를 종단 - * 그래프와 **같은 가로 스크롤러(canvas)** 안에 형제로 넣어 X축이 저절로 맞물리게 하고, - * 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다 — B06에서 겪은 - * "감싸는 상자 하나가 스크롤 폭 계산에 끼어들어 측점선이 어긋나는" 문제를 되풀이하지 않는다. + * 펼치면 12행 도면 테이블 자리를 덮고, 종단 그래프와 세로 1:1로 나눈다(2026-08-03 지시). + * 곡선 SVG는 종단 그래프와 **같은 가로 스크롤러(canvas)** 안 형제로 넣어 X축이 저절로 + * 맞물리게 하고, 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다. * ========================================================================== */ -import type { EarthworkConversion, HaulEquipmentLimit } from "@util/common_util_mass_haul_types"; -import type { MassHaulProfile, MassHaulStationSource } from "@util/common_util_mass_haul_types"; -import type { MassHaulResult, MassHaulSeries } from "@util/common_util_mass_haul"; +import type { + EarthworkConversion, + HaulEquipmentLimit, + MassHaulLongitudinal, + MassHaulSection, + MassHaulStationSource, +} from "@util/common_util_mass_haul_types"; +import type { MassHaulSeries } from "@util/common_util_mass_haul"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; import type { MassHaulAxis } from "@util/common_util_mass_haul_view"; -import { computeLongitudinalMassHaul, MASS_HAUL_BALANCE_KEY } from "@util/common_util_mass_haul"; +import { + computeMassHaulSeries, + MASS_HAUL_BALANCE_KEY, + MASS_HAUL_DEFAULT_VISIBLE, +} from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { @@ -30,32 +44,34 @@ import { MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createRouteDisposalPanel } from "./B05_wf2_Route_UI_Profile_Disposal"; -import "./B05_wf2_Route_UI_Style_Disposal.css"; import "@util/common_util_mass_haul.css"; import "./B05_wf2_Route_UI_Style_MassHaul.css"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ const OPEN_KEY = "b05-route-profile-masshaul-open"; -/** 표시 중인 곡선·레이어 키 보관. 곡선은 종단 하나뿐이라 사실상 분배 레이어 토글이다. */ -const VISIBLE_KEY = "b05-route-profile-masshaul-visible"; - -/** 계획 곡선은 종단 기준 하나뿐이지만, 범례·CSS 변형 클래스는 B06과 같은 키를 쓴다. */ -const PLAN_BASIS = "longitudinal" as const; +/** + * 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라 + * 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정). + * 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다. + */ +const VISIBLE_KEY = "b06:masshaul-visible-v4"; +const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; /** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */ export interface RouteMassHaulContext { conversion: EarthworkConversion; haulLimits?: HaulEquipmentLimit[]; - /** 표준횡단 설정의 노반폭(`road_width_m + shoulder_left_m + shoulder_right_m`). */ - roadbedWidthM: number; + /** 자연방토 판정 경사(rise/run). 못 받으면 전부 불가(보수적) — B06과 같은 규약. */ + naturalSpoilMinSlope?: number; } export interface RouteMassHaulDrawParams { /** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */ - longitudinal: MassHaulStationSource; - /** 편집이 반영된 현재 계획선(`toDesignProfile` 결과). */ - designProfile: MassHaulProfile; + stationSource: MassHaulStationSource; + /** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */ + longitudinal: MassHaulLongitudinal; + /** 횡단 정식 곡선 입력 — 상세 조회가 내려준 측점별 설계(기본 프리뷰 포함). */ + crossSections: MassHaulSection[]; /** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */ axis: MassHaulAxis; stationInterval: number; @@ -72,12 +88,6 @@ export interface RouteMassHaulPanel { bar: HTMLElement; isOpen(): boolean; setContext(next: RouteMassHaulContext | null): void; - /** 사토장·토취장 선정이 어느 경로를 대상으로 하는지 알린다. */ - setRoute(projectId: string, routeId: number | null): void; - /** 확정 저장에 실을 사토장·토취장 결과. 기준을 안 골랐으면 null. */ - disposalPayload(): Record | null; - /** 마지막으로 계산한 곡선(사토장·토취장 선정이 잉여/부족 입력으로 되받는다). */ - result(): MassHaulResult | null; /** * 곡선 SVG를 만든다. 펼침 상태가 아니거나 계산이 안 되면 null을 돌려주고, * 이때 호출한 쪽은 원래대로 도면 테이블을 그린다. @@ -85,6 +95,17 @@ export interface RouteMassHaulPanel { draw(params: RouteMassHaulDrawParams): SVGSVGElement | null; } +function readVisible(): Set { + try { + const raw = sessionStorage.getItem(VISIBLE_KEY); + if (!raw) return new Set(DEFAULT_VISIBLE); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE); + } catch { + return new Set(DEFAULT_VISIBLE); + } +} + /** * @param onChanged 펼침·범례 토글로 다시 그려야 할 때 호출된다(패널 전체 redraw). */ @@ -94,20 +115,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa handle.className = "b05-profile__masshaul-handle"; const caption = document.createElement("span"); caption.className = "b05-profile__masshaul-caption"; - caption.textContent = "계획 유토곡선"; + caption.textContent = "유토곡선"; handle.append(handleControl.root, caption); const bar = document.createElement("div"); bar.className = "b05-profile__masshaul-bar"; - const disposal = createRouteDisposalPanel(); let open = sessionStorage.getItem(OPEN_KEY) === "true"; let context: RouteMassHaulContext | null = null; - let lastResult: MassHaulResult | null = null; - const stored = sessionStorage.getItem(VISIBLE_KEY); - const visible = new Set( - stored ? (JSON.parse(stored) as string[]) : [PLAN_BASIS, MASS_HAUL_BALANCE_KEY], - ); function applyOpen(next: boolean): void { open = next; @@ -124,6 +139,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa caption.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { + const visible = readVisible(); if (visible.has(key)) visible.delete(key); else visible.add(key); sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible])); @@ -137,44 +153,36 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa setContext(next) { context = next; }, - setRoute(projectId, routeId) { - disposal.setRoute(projectId, routeId); - }, - disposalPayload: () => disposal.payload(), - result: () => lastResult, draw(params) { bar.replaceChildren(); - lastResult = null; if (!open) return null; - if (!context) { - const note = document.createElement("span"); - note.className = "b05-profile__masshaul-empty"; - note.textContent = "토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."; - bar.append(note); + const note = (message: string): null => { + const empty = document.createElement("span"); + empty.className = "b05-profile__masshaul-empty"; + empty.textContent = message; + bar.append(empty); return null; - } - const result = computeLongitudinalMassHaul( - { length_m: 0, design_profiles: [params.designProfile] }, + }; + if (!context) return note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); + // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + const series: MassHaulSeries[] = computeMassHaulSeries( + params.longitudinal, + params.crossSections, context.conversion, - { roadbedWidthM: context.roadbedWidthM }, + context.naturalSpoilMinSlope, ); - if (!result) { - disposal.update(null); - const note = document.createElement("span"); - note.className = "b05-profile__masshaul-empty"; - note.textContent = "계획선이 아직 없어 유토곡선을 그릴 수 없습니다."; - bar.append(note); - return null; - } - lastResult = result; - const series: MassHaulSeries[] = [{ key: PLAN_BASIS, basis: PLAN_BASIS, result }]; - const haulPlan: HaulPlan | null = visible.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(result, context.haulLimits) - : null; + if (!series.length) return note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); + const visible = readVisible(); + // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. + const banded = series.find((entry) => visible.has(entry.key)); + const haulPlan: HaulPlan | null = + banded && visible.has(MASS_HAUL_BALANCE_KEY) + ? computeHaulPlan(banded.result, context.haulLimits) + : null; const chart = createMassHaulChart( series, visible, - params.longitudinal, + params.stationSource, params.axis, params.selectedStationId, params.stationInterval, @@ -185,21 +193,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa haulPlan, ); bar.append( - createMassHaulLegend( - series, - visible, - toggleSeries, - () => { - resetBalloonOffsets(); - onChanged(); - }, - disposal.toggles, - ), - createMassHaulSummary(series[0], haulPlan), - disposal.panel, + createMassHaulLegend(series, visible, toggleSeries, () => { + resetBalloonOffsets(); + onChanged(); + }), ); - // 잔량(사토·토취 구간)이 바뀌었을 때만 서버를 다시 부른다 — 안에서 지문으로 거른다. - disposal.update(haulPlan); + const summarySeries = banded ?? series[0]; + bar.append(createMassHaulSummary(summarySeries, haulPlan)); return chart; }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 1607be44..2dcb3f8c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -47,6 +47,7 @@ import { toAlignmentBase, } from "./B05_wf2_Route_UI_Profile_Alignment"; import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; +import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createRouteMassHaulPanel, type RouteMassHaulContext, @@ -440,9 +441,13 @@ export function createRouteProfilePanel( fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO)); sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight)); } - const chartHeight = alignment - ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) - : available; + // 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 **1:1**로 나눈다(2026-08-03 사용자 지시) + // — 테이블용 고정 높이를 그대로 쓰면 곡선이 종단 그래프를 밀어낸다. + const chartHeight = massHaul.isOpen() + ? Math.max(MIN_CHART_HEIGHT, Math.floor(available / 2)) + : alignment + ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) + : available; const tableHeight = available - chartHeight; // 계획 유토곡선을 펼치면 도면 테이블은 그리지 않는다 — 같은 자리를 나눠 쓰면 둘 다 뭉개진다. @@ -559,8 +564,11 @@ export function createRouteProfilePanel( const massHaulChart = alignment && designProfiles[0] ? massHaul.draw({ - longitudinal: graphLongitudinal, - designProfile: designProfiles[0], + stationSource: graphLongitudinal, + // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 + // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. + longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, + crossSections: detail.cross_sections, axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 @@ -643,8 +651,12 @@ export function createRouteProfilePanel( // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; - // 사토장·토취장 선정은 경로 단위 결과라 대상 경로가 바뀌면 다시 받아야 한다. - massHaul.setRoute(projectId, routeId); + // 유토곡선 balloon 위치 캐시 — B06과 **같은 scope**를 써서 두 화면이 같은 자리를 공유한다. + // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다(B06과 같은 규칙). + configureBalloonOffsets( + `${projectId}:${routeId ?? "-"}`, + nextDetail.balloon_offsets ?? undefined, + ); draw(); requestAnimationFrame(draw); }, @@ -684,11 +696,6 @@ export function createRouteProfilePanel( if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; store.resetStation(chainageM); }, - /** - * 확정 저장에 실을 사토장·토취장 선정 결과(기준·부지 목록·사용자 정의 여부). - * 기준을 고르지 않았으면 null이라 확정 페이로드에서 통째로 빠진다. - */ - disposalPayload: () => massHaul.disposalPayload(), isDirty: () => store.dirty(), /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css deleted file mode 100644 index 5e7664bf..00000000 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style_Disposal.css +++ /dev/null @@ -1,121 +0,0 @@ -/* ============================================================================= - * B05_wf2_Route_UI_Style_Disposal.css - * 사토장·토취장 자동 선정 토글과 결과 카드 스타일. - * - * 토글은 유토곡선 범례 줄 안에 들어가므로 범례 항목(`b06-masshaul__legend-item`)의 - * 모양을 그대로 물려받고, 여기서는 라디오처럼 보이게 하는 상태 표시만 얹는다. - * ========================================================================== */ - -/* 다른 기준이 선택돼 잠긴 토글 — 눌리지 않는다는 것이 보여야 한다. */ -.b05-disposal__toggle:disabled { - cursor: default; - opacity: 0.45; -} - -.b05-disposal__toggle:not(.is-off) { - border-color: var(--color-primary); - color: var(--color-primary); - font-weight: 600; -} - -.b05-disposal { - display: flex; - flex-wrap: wrap; - align-items: stretch; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-16); - border-top: 1px solid var(--color-border); -} - -.b05-disposal__note { - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b05-disposal__site { - display: flex; - min-width: 260px; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-left-width: 3px; - border-radius: var(--radius-small); - background: var(--color-surface-raised); - font-size: var(--text-caption); -} - -/* 사토(내보내는 흙)와 토취(들여오는 흙)는 방향이 반대라 왼쪽 띠 색으로 갈라 놓는다. */ -.b05-disposal__site--spoil { - border-left-color: var(--color-warning, #d97706); -} - -.b05-disposal__site--borrow { - border-left-color: var(--color-info, #2563eb); -} - -.b05-disposal__head { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.b05-disposal__badge { - padding: 0 var(--spacing-4); - border: 1px solid var(--color-primary); - border-radius: var(--radius-pills); - color: var(--color-primary); - font-size: 11px; -} - -.b05-disposal__metrics { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - color: var(--color-text-secondary); -} - -.b05-disposal__metric { - display: inline-flex; - align-items: baseline; - gap: var(--spacing-4); -} - -.b05-disposal__metric strong { - color: var(--color-text-body); -} - -.b05-disposal__edit { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8); -} - -.b05-disposal__field { - display: flex; - flex-direction: column; - gap: 2px; - color: var(--color-text-secondary); -} - -.b05-disposal__field input { - width: 96px; - box-sizing: border-box; - padding: 2px var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-small); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); -} - -.b05-disposal__reset { - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: 11px; - cursor: pointer; -} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index f98ab03d..f8ecc969 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -47,6 +47,8 @@ export interface RouteViewer { setContoursVisible: (visible: boolean) => void; setAxesVisible: (visible: boolean) => void; setStationLinesVisible: (visible: boolean) => void; + /** 구조물(비정규) 측점의 번호·이름 라벨 표시 토글. */ + setStationLabelsVisible: (visible: boolean) => void; renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; beginMoveSelected: () => void; @@ -370,6 +372,7 @@ export function createRouteViewer(): RouteViewer { axes.visible = visible; }, setStationLinesVisible: markers.setStationLinesVisible, + setStationLabelsVisible: markers.setStationLabelsVisible, renderStationLines: markers.renderStationLines, setView: fit, beginMoveSelected() { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 9d07d295..919f7089 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -15,7 +15,6 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; -import type { DisposalSitesSnapshot } from "@util/common_util_mass_haul_sites"; import type { BalloonOffsets, EarthworkConversion, @@ -182,11 +181,6 @@ export interface LongitudinalSection { * 구조는 `B05_wf2_Route_UI_Profile_Alignment.ProfileAlignment`. */ profile_alignment?: unknown; - /** - * B05 계획 단계에서 고른 사토장·토취장(확정 시 종단 정본에 심긴다). - * B06은 고르지 않고 **받아 쓰기만** 한다 — 부지 선정은 계획선을 바꾸는 문제라 B05 몫이다. - */ - disposal_sites?: DisposalSitesSnapshot; } export interface CrossSection extends SectionStation { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index b9ed8728..cf1d0419 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -459,10 +459,15 @@ def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: def _attach_default_designs( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] ) -> None: - """지정 설계가 없는 횡단에 기본값(토사 + 상단측 절토) 프리뷰 설계를 즉석 계산해 얹는다. + """지정 설계가 없는 횡단에 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토) 프리뷰를 얹는다. detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이 전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다. + + 기본 지반을 토사가 아니라 **리핑암 + 지표 아래 0.5m 암반 경계**로 두는 이유(2026-08-03 + 사용자 확정): 산지 절토는 대부분 표토 아래에서 암이 나오므로, 전량 토사 가정은 물량이 + 낙관적으로 나온다. 지표 0.5m까지 토사·그 아래 리핑암인 2단 단면이 안전한 출발값이고, + 발파암이 있으면 사용자가 B06에서 측점별로 고친다. """ default_modes = _default_section_modes(longitudinal) pavement = _pavement_suggestions(longitudinal) @@ -475,9 +480,10 @@ def _attach_default_designs( design = compute_cross_design( section.get("samples", []), design_elevation_from_longitudinal(longitudinal, chainage_m), - ground_type="soil", + ground_type="ripping_rock", section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), paved=suggested, + rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, ) design["status"] = "provisional" design["pavement_suggested"] = suggested @@ -492,7 +498,7 @@ def _compute_default_designs( chainages: list[float], standard: dict[str, Any] | None = None, ) -> list[tuple[float, dict[str, Any]]]: - """미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다. + """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). @@ -519,10 +525,11 @@ def _compute_default_designs( design = compute_cross_design( samples, design_elevation_from_longitudinal(longitudinal, chainage_m), - ground_type="soil", + ground_type="ripping_rock", section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), paved=suggested, standard=standard, + rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, ) design["status"] = "provisional" design["pavement_suggested"] = suggested diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index da08228b..5af3f0f7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -36,7 +36,6 @@ import { } from "./B06_wf3_ProfileCross_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; -import { disposalSitesPayload } from "@util/common_util_mass_haul_sites"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { @@ -416,16 +415,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { context.natural_spoil_min_ground_slope ?? undefined, ) : null; - const haulPlan = result ? computeHaulPlan(result, context?.haul_equipment_limits) : null; return { crossPatches, massHaul: result ? massHaulPayload( result, - haulPlan, + computeHaulPlan(result, context?.haul_equipment_limits), balloonOffsetsPayload(), - // B05 선정 결과를 정식 곡선 기준 운반거리로 다시 재 실어 B08이 그대로 받게 한다. - disposalSitesPayload(sectionDetail?.longitudinal.disposal_sites, haulPlan), ) : undefined, }; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 48998180..63aaba81 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -43,7 +43,6 @@ import { configureBalloonOffsets, resetBalloonOffsets, } from "@util/common_util_mass_haul_balance_view"; -import { createDisposalSummary } from "@util/common_util_mass_haul_sites"; import { createMassHaulChart, createMassHaulLegend, @@ -527,9 +526,6 @@ export function createSectionView( if (summarySeries) { panelCount.textContent = ""; panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); - // B05가 고른 사토장·토취장 — 정식 곡선의 잔량 위치로 운반거리를 다시 재 표시한다. - const disposal = createDisposalSummary(detail.longitudinal.disposal_sites, haulPlan); - if (disposal) panelBody.append(disposal); } else { panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index c70cf9b0..b6efa9e4 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -95,16 +95,34 @@ /* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다. **실선**이다(2026-08-02 사용자 지시) — 도면에서 파선은 장비 경계현의 몫이라, 0선까지 파선이면 둘이 구분되지 않는다. */ +/* 0선은 절·성 우세를 가르는 기준선 — 붉은 굵은 실선으로 다른 격자와 확실히 구분한다 + (2026-08-03 사용자 지시). */ .b06-masshaul__zero { - stroke: var(--color-text-secondary); - stroke-width: 1.2; + stroke: var(--color-danger, #d32f2f); + stroke-width: 2.5; } .b06-masshaul__zero-tick { - fill: var(--color-text-secondary); + fill: var(--color-danger, #d32f2f); font-weight: var(--font-weight-medium); } +/* EP(종점) 잔량 라벨 — 곡선 위에 놓이므로 불투명 판을 깐다. */ +.b06-masshaul__ep-dot { + fill: var(--color-danger, #d32f2f); +} + +.b06-masshaul__ep-plate { + fill: var(--color-surface); + stroke: var(--color-border); +} + +.b06-masshaul__ep-label { + fill: var(--color-text-body); + font-size: 11px; + font-weight: 600; +} + /* 그래프 이름표 배경 — 이름이 곡선 위에 놓이므로 불투명하게 깐다. */ .b06-masshaul__name-plate { fill: var(--color-surface); @@ -353,17 +371,3 @@ border-top-color: var(--color-warning); border-top-style: dashed; } - -/* 범례 줄 안에서 컨트롤 묶음을 가르는 세로 구분기호(사토장·토취장 토글 양옆). */ -.b06-masshaul__legend-divider { - width: 1px; - height: 16px; - flex: none; - background: var(--color-border); -} - -/* 사토장·토취장 줄 — 요약 줄과 같은 양식이되 위 줄과 이어 붙은 것으로 읽히게 경계를 지운다. */ -.b06-masshaul__summary--sites { - border-top: none; - padding-top: 0; -} diff --git a/common_util/common_util_mass_haul.ts b/common_util/common_util_mass_haul.ts index 65d25a94..df9da3e0 100644 --- a/common_util/common_util_mass_haul.ts +++ b/common_util/common_util_mass_haul.ts @@ -392,61 +392,6 @@ function longitudinalAreaSamples( }); } -/** - * 횡단 설계가 아직 없을 때 쓰는 종단 개략 곡선의 입력. - * - * B05 노선 설계 단계에는 측점별 횡단 설계(`CrossDesign`)가 없다 — 그건 B06에서 만든다. - * 그래서 노반폭을 표준횡단 설정(`road_width_m + shoulder_left_m + shoulder_right_m`)에서 - * 한 값으로 받아 전 구간에 같게 물린다. 계획선을 끌어 옮기며 절·성토 균형을 잡는 - * 용도라 이 정도 개략값이면 충분하고, 정식 물량은 B06 횡단 기준 곡선이 낸다. - */ -export interface LongitudinalMassHaulOptions { - /** 전 구간 공통 노반폭(m). */ - roadbedWidthM: number; - /** - * 절토 전량에 물릴 지반유형. B05는 암반 경계선을 모르므로 기본값은 토사이며, - * 그만큼 다짐환산이 보수적으로(작게) 잡힌다. - */ - groundType?: GroundType; -} - -/** - * 횡단 설계 없이 종단 계획선만으로 개략 유토곡선을 만든다(B05 계획 단계). - * - * `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다. 단면적은 그 높이차에 - * 노반폭을 곱한 사각형으로 어림한다 — 비탈면 몫이 빠져 실제보다 작게 나오지만, 국내 - * 설계기준이 종단 기반 토량을 노선계획 단계의 **개산용**으로만 두고 별도 산식을 정하지 - * 않으므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지는 않는다. - * 자연방토는 성토측 지반 경사를 알 수 없어 전부 불가로 본다(보수적). - */ -export function computeLongitudinalMassHaul( - longitudinal: MassHaulLongitudinal, - conversion: EarthworkConversion, - options: LongitudinalMassHaulOptions, -): MassHaulResult | null { - const profile = longitudinal.design_profiles?.[0]; - const width = finiteArea(options.roadbedWidthM); - if (!profile || !(width > 0)) return null; - const ground = normalizeGround(options.groundType); - const samples = profile.samples - .filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m)) - .sort((a, b) => a.chainage_m - b.chainage_m); - const areas: AreaSample[] = samples.map((sample, index) => { - const height = sample.difference_m; - const cutArea = height < 0 ? -height * width : 0; - return { - station_id: `plan@${index}`, - chainage_m: sample.chainage_m, - cut_soil_area_m2: ground === "soil" ? cutArea : 0, - cut_rock_area_m2: ground === "soil" ? 0 : cutArea, - rock_kind: ground === "soil" ? null : ground, - fill_area_m2: height > 0 ? height * width : 0, - natural_spoil: false, - }; - }); - return integrate(areas, conversion); -} - /** * 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산). * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. @@ -493,16 +438,12 @@ export function massHaulPayload( result: MassHaulResult, haulPlan?: HaulPlan | null, balloonOffsets?: Record, - /** B05가 고른 사토장·토취장 — 정식 곡선 기준으로 운반거리를 다시 잰 형태로 받는다. */ - disposalSites?: Record, ): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { basis: "compacted", conversion: result.conversion, ...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}), - // B08 내역서가 사토·토취 운반 항목을 세우는 입력. 선정이 없으면 키 자체가 빠진다. - ...(disposalSites ? { disposal_sites: disposalSites } : {}), // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), cut_natural_m3: { diff --git a/common_util/common_util_mass_haul_sites.ts b/common_util/common_util_mass_haul_sites.ts deleted file mode 100644 index 2c8f1350..00000000 --- a/common_util/common_util_mass_haul_sites.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_sites.ts - * 사토장·토취장 선정 결과를 유토곡선 옆에 표시하고 운반거리를 다시 재는 공용 모듈. - * - * 부지를 **고르는** 일은 B05 계획 단계에서 끝난다(계획선을 바꿔야 정리되는 문제라서다). - * B06은 그 결과를 그대로 받아, 실측 단면적으로 낸 **정식 곡선**의 잔량 위치를 기준으로 - * 운반거리를 다시 재 표시하고 B08 내역서로 넘긴다 — 계획 곡선과 정식 곡선은 잔량이 - * 놓이는 자리가 다르므로, B05가 적어 둔 거리를 그대로 쓰면 물량과 어긋난다. - * ========================================================================== */ - -import type { HaulPlan } from "./common_util_mass_haul_balance"; - -/** B05가 종단 정본(`longitudinal.disposal_sites`)에 심어 둔 부지 하나. */ -export interface DisposalSiteRecord { - id: string; - kind: "spoil" | "borrow"; - chainage_m: number; - offset_m: number; - side: "left" | "right"; - x: number; - y: number; - elevation_m: number; - capacity_m3: number; - assigned_m3: number; - /** B05 계획 곡선 기준 운반거리(m). 표시·저장에는 아래 재계산값을 쓴다. */ - haul_distance_m: number; - /** 사용자가 위치나 용량을 손으로 고친 부지인가. */ - custom: boolean; -} - -export interface DisposalSitesSnapshot { - /** 선정 기준 — `cost` | `stability` | `valley` | `clearing`. */ - option: string; - sites: DisposalSiteRecord[]; -} - -const OPTION_LABEL: Record = { - cost: "비용", - stability: "지형안정성", - valley: "계곡부", - clearing: "임내 공간", -}; - -/** 잔량이 몰려 있는 대표 위치(m). 없으면 null. */ -function residualCenter(plan: HaulPlan | null, kind: "spoil" | "borrow"): number | null { - const residuals = (plan?.residuals ?? []).filter((residual) => residual.kind === kind); - const total = residuals.reduce( - (sum, residual) => - sum + - (kind === "spoil" - ? Math.max(residual.volume_m3 - residual.natural_m3, 0) - : residual.volume_m3), - 0, - ); - if (total <= 0) return null; - const weighted = residuals.reduce((sum, residual) => { - const volume = - kind === "spoil" ? Math.max(residual.volume_m3 - residual.natural_m3, 0) : residual.volume_m3; - return sum + volume * 0.5 * (residual.from_m + residual.to_m); - }, 0); - return weighted / total; -} - -/** 정식 곡선의 잔량 위치를 기준으로 각 부지의 운반거리를 다시 잰 목록. */ -export function resolveDisposalSites( - snapshot: DisposalSitesSnapshot | null | undefined, - plan: HaulPlan | null, -): DisposalSiteRecord[] { - if (!snapshot?.sites?.length) return []; - const centers = { - spoil: residualCenter(plan, "spoil"), - borrow: residualCenter(plan, "borrow"), - }; - return snapshot.sites.map((site) => { - const center = centers[site.kind]; - if (center === null) return site; - const distance = Math.abs(site.chainage_m - center) + Math.abs(site.offset_m); - return { ...site, haul_distance_m: Math.round(distance * 10) / 10 }; - }); -} - -/** - * 곡선 아래 요약 줄 다음에 붙는 사토장·토취장 줄. 선정 결과가 없으면 null. - * 표시 수치는 정식 곡선 기준으로 다시 잰 값이라 B05 화면과 거리가 다를 수 있다. - */ -export function createDisposalSummary( - snapshot: DisposalSitesSnapshot | null | undefined, - plan: HaulPlan | null, -): HTMLElement | null { - const sites = resolveDisposalSites(snapshot, plan); - if (!sites.length) return null; - const row = document.createElement("div"); - row.className = "b06-masshaul__summary b06-masshaul__summary--sites"; - const basis = document.createElement("span"); - basis.className = "b06-masshaul__basis"; - basis.textContent = `선정 기준 ${OPTION_LABEL[snapshot?.option ?? ""] ?? snapshot?.option ?? "-"}`; - row.append(basis); - for (const site of sites) { - const chip = document.createElement("span"); - chip.className = "b06-masshaul__chip"; - const name = document.createElement("em"); - name.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"}${site.custom ? "*" : ""}`; - const value = document.createElement("strong"); - value.textContent = - `${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}` + - `${Math.abs(site.offset_m).toFixed(0)}m · ${site.assigned_m3.toLocaleString()}㎥ · ` + - `L=${site.haul_distance_m.toFixed(0)}m`; - chip.append(name, value); - row.append(chip); - } - const note = document.createElement("span"); - note.className = "b06-masshaul__chip"; - note.textContent = "* 사용자 정의"; - row.append(note); - return row; -} - -/** 확정 저장·B08 인계용 직렬화. 운반거리는 정식 곡선 기준으로 다시 잰 값을 싣는다. */ -export function disposalSitesPayload( - snapshot: DisposalSitesSnapshot | null | undefined, - plan: HaulPlan | null, -): Record | undefined { - const sites = resolveDisposalSites(snapshot, plan); - if (!sites.length) return undefined; - return { option: snapshot?.option, sites }; -} diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 6ba26fac..09478876 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -415,6 +415,37 @@ export function createMassHaulChart( ); } + // EP(종점) 잔량 라벨 — 곡선 끝점의 누가토량이 곧 노선 전체의 잉여(+)/부족(−)이라 + // 눈금을 되짚지 않아도 읽히게 값 자체를 적는다(2026-08-03 사용자 지시). 단위 ㎥ 명기. + if (banded) { + const last = banded.result.points[banded.result.points.length - 1]; + const epX = x(last.chainage_m); + const epY = y(last.cumulative_volume_m3); + const text = `EP ${formatVolume(last.cumulative_volume_m3)}㎥`; + const labelWidth = textWidth(text) + 10; + // 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다. + const labelX = Math.max(epX - 8, axis.padLeft + labelWidth); + const labelY = Math.min(Math.max(epY - 8, MASS_PAD_TOP + 12), heightPx - MASS_PAD_BOTTOM - 4); + svg.append( + svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }), + svgElement("rect", { + x: labelX - labelWidth, + y: labelY - 12, + width: labelWidth, + height: 16, + rx: 4, + ry: 4, + class: "b06-masshaul__ep-plate", + }), + svgText(text, { + x: labelX - 5, + y: labelY, + "text-anchor": "end", + class: "b06-masshaul__ep-label", + }), + ); + } + // 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을 // 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면). if (haulPlan) { @@ -508,11 +539,6 @@ export function createMassHaulLegend( visibleKeys: ReadonlySet, onToggle: (key: string) => void, onResetBalloons?: () => void, - /** - * 토량 분배 토글과 도형 위치 초기화 버튼 **사이**에 끼워 넣을 컨트롤(양쪽에 구분기호가 선다). - * B05 사토장·토취장 선정 토글이 이 자리에 들어간다(2026-08-03 사용자 지시). - */ - extras?: HTMLElement[], ): HTMLElement { const legend = document.createElement("div"); legend.className = "b06-masshaul__legend"; @@ -544,15 +570,6 @@ export function createMassHaulLegend( L("B06_MassHaul_Balance_Layer"), "b06-masshaul__legend-item b06-masshaul__legend-item--balance", ); - if (extras?.length) { - const divider = (): HTMLElement => { - const line = document.createElement("span"); - line.className = "b06-masshaul__legend-divider"; - line.setAttribute("aria-hidden", "true"); - return line; - }; - legend.append(divider(), ...extras, divider()); - } // 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소). if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) { const reset = document.createElement("button"); diff --git a/config/config_system.py b/config/config_system.py index cb596a9b..69b04c5c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -481,46 +481,6 @@ EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 -# ───────────────────────────────────────────────────────────────────────── -# 5-4-6. 사토장·토취장 후보지 자동 선정 (B05 계획 유토곡선) -# -# 유토곡선이 낸 잉여(사토)·부족(토취) 토량을 어디서 처리할지 노선 주변 지형에서 찾는다. -# 실무 선정 기준(토목시공학 계열 정리, 2026-08-03 조사)을 격자 판정으로 옮긴 값이다: -# 사토장 — 평탄지·완경사지, 계곡부·하천 주변은 집중호우 유실 위험으로 제외, -# 트럭이 오르막을 타지 않도록 하향 운반 우대 -# 토취장 — 양질의 토사·리핑암, 부족 구간에서 최단거리 -# -# 탐색은 **노선 좌우 corridor 안**에서만 한다(2026-08-03 사용자 확정) — 노선에서 먼 곳은 -# 운반비가 실무적 의미를 잃고 계산만 무거워진다. 값은 여기가 유일한 정의처다. -# ───────────────────────────────────────────────────────────────────────── -DISPOSAL_SITE_CRITERIA = { - # 노선 중심선 좌우로 훑는 폭(m). 이 밖은 후보로 보지 않는다. - "corridor_half_width_m": 80.0, - # 노선을 따라가며 후보를 찍는 간격(m)과 좌우로 벌리는 간격(m). - "along_spacing_m": 20.0, - "offset_spacing_m": 10.0, - # 후보 중심에서 평탄성을 재는 반경(m). 이 안의 격자 경사로 부지 적합성을 판정한다. - "flat_radius_m": 15.0, - # 사토장 허용 지반경사 상한(rise/run). 넘으면 쌓은 흙이 흘러내려 부지로 못 쓴다. - "max_ground_slope": 0.25, - # 토취장 허용 지반경사 상한 — 파내는 쪽이라 사토장보다 관대하다. - "borrow_max_ground_slope": 0.45, - # 부지에 쌓을 수 있다고 보는 평균 성토 두께(m). 용량 = 평탄면적 × 이 값. - "fill_depth_m": 3.0, - # 토취장에서 파낼 수 있다고 보는 평균 굴착 깊이(m). - "borrow_depth_m": 4.0, - # 계곡 축(skeleton valley)에서 이 거리 안은 유실 위험 구역으로 감점한다(m). - # `valley` 옵션에서는 반대로 이 안쪽만 후보로 삼는다(암거 연계 매립). - "valley_buffer_m": 25.0, - # 옵션별로 돌려주는 후보지 개수 상한(사토·토취 각각). - "max_candidates": 5, - # 식생 높이가 이 값 이하인 격자를 공터·벌채지로 본다(m). `clearing` 옵션 판정 기준. - "clearing_max_canopy_m": 2.0, - # 공터 판정에 쓰는 라이다 격자 크기(m). 너무 잘면 점이 없는 칸이 늘어 판정이 흔들린다. - "clearing_grid_m": 5.0, -} - - # ───────────────────────────────────────────────────────────────────────── # 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) # diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 27f250a4..89d640f8 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -141,6 +141,7 @@ export const ui_locales_b2 = { B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"], + B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"], /* --- B06_wf3_ProfileCross 종·횡단 생성 --- */ B06_Profile_Title: ["횡단설계", "Cross Design"],