Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
|
||||
nodes: RoutePlanNode[];
|
||||
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
|
||||
curves: RoutePlanCurve[];
|
||||
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
|
||||
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
|
||||
min_radius_m: number;
|
||||
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
|
||||
limit_radius_m?: number;
|
||||
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
|
||||
limit_curve_length_m?: number;
|
||||
curve_count: number;
|
||||
violation_count: number;
|
||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||
@@ -128,6 +132,60 @@ export async function replanRoute(
|
||||
);
|
||||
}
|
||||
|
||||
/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧).
|
||||
*
|
||||
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
|
||||
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */
|
||||
export async function fetchRouteElevations(
|
||||
projectId: string,
|
||||
points: Array<[number, number]>,
|
||||
): Promise<Array<number | null>> {
|
||||
const payload = await requestJson<{ z: Array<number | null> }>(
|
||||
`/projects/${projectId}/route/elevations`,
|
||||
{ method: "POST", body: JSON.stringify({ points }) },
|
||||
60000,
|
||||
);
|
||||
return payload.z;
|
||||
}
|
||||
|
||||
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
|
||||
export interface CrossPreviewResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
uphill_side: string | null;
|
||||
plan_radius_m: number | null;
|
||||
curve_widening_m: number | null;
|
||||
/** 원지반 횡단 샘플. */
|
||||
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
|
||||
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
|
||||
design: {
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CrossPreviewRequest {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
chainage_m: number;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
export async function fetchCrossPreview(
|
||||
projectId: string,
|
||||
request: CrossPreviewRequest,
|
||||
): Promise<CrossPreviewResponse> {
|
||||
return requestJson<CrossPreviewResponse>(
|
||||
`/projects/${projectId}/route/cross-preview`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
120000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
|
||||
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
|
||||
return float(speeds[terrain])
|
||||
|
||||
|
||||
def plan_radius_limit_m(
|
||||
grade_class: str,
|
||||
design_speed_kph: int | None = None,
|
||||
terrain_type: str = "normal",
|
||||
) -> float:
|
||||
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
|
||||
|
||||
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
|
||||
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
|
||||
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
|
||||
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
|
||||
"""
|
||||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
|
||||
override = table.get(grade_class)
|
||||
if override is not None:
|
||||
return float(override)
|
||||
return legal_plan_radius_min_m(
|
||||
resolve_design_speed(grade_class, design_speed_kph), terrain_type
|
||||
)
|
||||
|
||||
|
||||
def plan_curve_length_limit_m(grade_class: str) -> float:
|
||||
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
|
||||
|
||||
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
|
||||
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
|
||||
"""
|
||||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
|
||||
return float(table.get(grade_class) or 0.0)
|
||||
|
||||
|
||||
def _pick(*candidates: Any) -> Any:
|
||||
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
|
||||
for value in candidates:
|
||||
|
||||
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
|
||||
route_signature,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
|
||||
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
|
||||
|
||||
|
||||
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
|
||||
|
||||
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`…_curves.json`). 정점 목록만으로는
|
||||
어디부터 어디까지가 한 곡선이고 반지름이 얼마인지 알 수 없어, 곡선부 확폭을 측점마다
|
||||
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
|
||||
|
||||
수정본(`planned_route.csv`)이 있으면 **그 곡선표만** 쓴다 — 노선을 고쳤는데 초기본
|
||||
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
|
||||
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
|
||||
"""
|
||||
route_path = planned_route_working_path(project_root)
|
||||
if not route_path.is_file():
|
||||
route_path = planned_route_initial_path(project_root)
|
||||
target = route_path.with_name(f"{route_path.stem}_curves.json")
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get("curves") if isinstance(data, dict) else None
|
||||
return (
|
||||
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
||||
)
|
||||
|
||||
|
||||
def cross_filename(chainage_m: float) -> str:
|
||||
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
|
||||
return f"cross_{int(round(float(chainage_m))):05d}m.json"
|
||||
@@ -361,6 +393,9 @@ def run_section_generation(
|
||||
replace(options or SectionGenerationOptions(), extra_stations=extras),
|
||||
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
|
||||
crs=crs,
|
||||
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
|
||||
# 종전의 측점별 실측 반경으로 물러선다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
@@ -473,6 +508,8 @@ def generate_irregular_sections(
|
||||
merged_options,
|
||||
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
|
||||
crs=crs,
|
||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
irregular_stations = [
|
||||
station
|
||||
|
||||
@@ -213,6 +213,101 @@ def _curve_widenings(
|
||||
return widenings, sides
|
||||
|
||||
|
||||
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
|
||||
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
|
||||
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
|
||||
|
||||
|
||||
def _project_chainage(
|
||||
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
|
||||
) -> tuple[float, float]:
|
||||
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
|
||||
starts = points[:-1, :2]
|
||||
vectors = points[1:, :2] - starts
|
||||
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
|
||||
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
|
||||
target = np.asarray(xy, dtype=np.float64)
|
||||
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
|
||||
feet = starts + vectors * ratios[:, None]
|
||||
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
|
||||
index = int(np.argmin(distances))
|
||||
span = route_chainage[index + 1] - route_chainage[index]
|
||||
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
|
||||
|
||||
|
||||
def _design_curve_spans(
|
||||
points: np.ndarray,
|
||||
route_chainage: np.ndarray,
|
||||
curves: list[dict[str, Any]],
|
||||
) -> list[tuple[float, float, float, str]]:
|
||||
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
|
||||
|
||||
바깥쪽은 회전 방향으로 가른다 — 시점→교점→종점의 외적 z가 양수면 좌회전이라 안쪽이
|
||||
좌측이고 바깥은 우측이다(`_plan_radii` 와 같은 규약). 노선에서 멀리 떨어진 곡선과
|
||||
방향을 못 재는 곡선은 버린다.
|
||||
"""
|
||||
spans: list[tuple[float, float, float, str]] = []
|
||||
for curve in curves:
|
||||
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
|
||||
radius = curve.get("radius_m")
|
||||
if not (start and apex and end) or not isinstance(radius, (int, float)):
|
||||
continue
|
||||
start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
|
||||
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
|
||||
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
|
||||
continue
|
||||
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
|
||||
end[0] - apex[0]
|
||||
)
|
||||
if abs(cross) < 1e-12:
|
||||
continue
|
||||
spans.append(
|
||||
(
|
||||
min(start_chainage, end_chainage),
|
||||
max(start_chainage, end_chainage),
|
||||
float(radius),
|
||||
"right" if cross > 0 else "left",
|
||||
)
|
||||
)
|
||||
return sorted(spans)
|
||||
|
||||
|
||||
def _design_curve_widenings(
|
||||
station_chainage: np.ndarray,
|
||||
spans: list[tuple[float, float, float, str]],
|
||||
) -> tuple[list[float | None], list[str | None], list[float]]:
|
||||
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
|
||||
|
||||
곡선 **안**은 그 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 한 값이다. 곡선 앞뒤
|
||||
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 그 밖은 확폭이
|
||||
없다. 곡선이 겹치면 **확폭이 큰 쪽**을 따르고, 반경은 작은 쪽(급한 쪽)을 남긴다.
|
||||
"""
|
||||
count = len(station_chainage)
|
||||
radii: list[float | None] = [None] * count
|
||||
sides: list[str | None] = [None] * count
|
||||
widenings: list[float] = [0.0] * count
|
||||
for start, end, radius, side in spans:
|
||||
table = curve_widening_m(radius)
|
||||
for index, value in enumerate(station_chainage):
|
||||
chainage = float(value)
|
||||
inside = start - 1e-9 <= chainage <= end + 1e-9
|
||||
if inside and (radii[index] is None or radius < radii[index]):
|
||||
radii[index] = round(radius, 3)
|
||||
if table <= 0.0:
|
||||
continue
|
||||
if inside:
|
||||
amount = table
|
||||
else:
|
||||
distance = start - chainage if chainage < start else chainage - end
|
||||
if distance > CURVE_WIDENING_TAPER_M:
|
||||
continue
|
||||
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
|
||||
if amount > widenings[index]:
|
||||
widenings[index] = amount
|
||||
sides[index] = side
|
||||
return radii, sides, widenings
|
||||
|
||||
|
||||
def generate_sections(
|
||||
polyline: np.ndarray | list[list[float]],
|
||||
sampler: SurfaceElevationSampler,
|
||||
@@ -220,6 +315,7 @@ def generate_sections(
|
||||
*,
|
||||
source_snapshot: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
design_curves: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
||||
options = options or SectionGenerationOptions()
|
||||
@@ -267,11 +363,21 @@ def generate_sections(
|
||||
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
|
||||
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
|
||||
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
|
||||
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
|
||||
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
|
||||
plan_widenings, plan_outer_sides = _curve_widenings(
|
||||
station_chainage, plan_radii, plan_outer_sides
|
||||
)
|
||||
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
|
||||
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
|
||||
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
|
||||
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
|
||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||
if spans:
|
||||
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
|
||||
station_chainage, spans
|
||||
)
|
||||
else:
|
||||
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
|
||||
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
|
||||
plan_widenings, plan_outer_sides = _curve_widenings(
|
||||
station_chainage, plan_radii, plan_outer_sides
|
||||
)
|
||||
|
||||
offsets = np.arange(
|
||||
-options.cross_half_width_m,
|
||||
|
||||
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
|
||||
|
||||
|
||||
async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
|
||||
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
|
||||
criteria = await _plan_criteria(project_id)
|
||||
return criteria[0]
|
||||
|
||||
|
||||
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
|
||||
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
|
||||
|
||||
기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다
|
||||
(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이
|
||||
아예 안 그려지므로 반드시 갈라 둔다.
|
||||
|
||||
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
|
||||
|
||||
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미
|
||||
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다.
|
||||
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
"""
|
||||
import aiomysql
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
legal_plan_radius_min_m,
|
||||
plan_curve_length_limit_m,
|
||||
plan_radius_limit_m,
|
||||
resolve_design_speed,
|
||||
)
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
|
||||
grade_class, design_speed, terrain = "work", None, "special"
|
||||
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
terrain = str(params["terrain_type"])
|
||||
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
|
||||
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
|
||||
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
|
||||
return (
|
||||
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
|
||||
plan_radius_limit_m(grade_class, design_speed, terrain),
|
||||
plan_curve_length_limit_m(grade_class),
|
||||
)
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
if not expected:
|
||||
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
|
||||
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
|
||||
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
|
||||
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
|
||||
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
|
||||
"min_radius_m": round(radius_m, 2),
|
||||
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
|
||||
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
|
||||
"limit_radius_m": round(radius_limit_m, 2),
|
||||
"limit_curve_length_m": round(arc_limit_m, 2),
|
||||
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
||||
"violation_count": outline.violation_count,
|
||||
"edited": bool(working),
|
||||
@@ -468,7 +493,7 @@ async def replan_route(
|
||||
|
||||
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
|
||||
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
|
||||
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
project_root, stored_path = paths
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
working_path = planned_route_working_path(project_root)
|
||||
if working_path.is_file():
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
|
||||
|
||||
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어
|
||||
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가
|
||||
있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
|
||||
않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
|
||||
|
||||
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
|
||||
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
|
||||
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
|
||||
|
||||
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
|
||||
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
|
||||
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
|
||||
MAX_POINTS = 4000
|
||||
|
||||
|
||||
class ElevationRequest(BaseModel):
|
||||
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
|
||||
|
||||
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
|
||||
|
||||
|
||||
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
|
||||
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
|
||||
return z, valid
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/elevations", response_model=None)
|
||||
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
|
||||
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
|
||||
|
||||
지표면 밖이거나 자료가 없는 자리는 `valid=false` 로 나가고 표고는 `null` 이다 —
|
||||
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 그 자리를 「모름」으로 낸다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
|
||||
if sampled is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
|
||||
},
|
||||
)
|
||||
z, valid = sampled
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
|
||||
"valid": [bool(ok) for ok in valid],
|
||||
}
|
||||
|
||||
|
||||
class PreviewVertex(BaseModel):
|
||||
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
radius_m: float | None = None
|
||||
|
||||
|
||||
class CrossPreviewRequest(BaseModel):
|
||||
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
|
||||
|
||||
vertices: list[PreviewVertex] = Field(..., min_length=2)
|
||||
chainage_m: float = Field(..., ge=0)
|
||||
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
||||
min_radius_m: float = Field(12.0, gt=0)
|
||||
station_interval_m: float | None = None
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
project_root: Path,
|
||||
params: dict,
|
||||
request: CrossPreviewRequest,
|
||||
) -> dict | None:
|
||||
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
|
||||
|
||||
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
|
||||
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
|
||||
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
|
||||
|
||||
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
|
||||
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
|
||||
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
|
||||
"""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
|
||||
built = build_planned_polyline(
|
||||
[(vertex.x, vertex.y) for vertex in request.vertices],
|
||||
min_radius_m=request.min_radius_m,
|
||||
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
|
||||
simplify=False,
|
||||
curve_flags=[vertex.curve for vertex in request.vertices],
|
||||
radii=[vertex.radius_m for vertex in request.vertices],
|
||||
)
|
||||
interval = request.station_interval_m
|
||||
options = (
|
||||
SectionGenerationOptions(station_interval_m=float(interval))
|
||||
if interval and interval > 0
|
||||
else SectionGenerationOptions()
|
||||
)
|
||||
result = generate_sections(built.vertices, sampler, options)
|
||||
sections = result["cross_sections"]
|
||||
if not sections:
|
||||
return None
|
||||
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
|
||||
|
||||
design = None
|
||||
center_z = section.get("center_z")
|
||||
if center_z is not None:
|
||||
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
|
||||
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
|
||||
design = compute_cross_design(
|
||||
section["samples"],
|
||||
float(center_z),
|
||||
ground_type="soil",
|
||||
section_mode=section_mode,
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
return {
|
||||
"chainage_m": round(float(section["chainage_m"]), 3),
|
||||
"label": section.get("label"),
|
||||
"uphill_side": section.get("uphill_side"),
|
||||
"plan_radius_m": section.get("plan_radius_m"),
|
||||
"curve_widening_m": section.get("curve_widening_m"),
|
||||
"samples": section["samples"],
|
||||
"design": design,
|
||||
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
||||
if result.get("longitudinal", {}).get("total_length_m") is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/cross-preview", response_model=None)
|
||||
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
|
||||
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
|
||||
|
||||
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **preview}
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
dragHandleTo as curveDragTo,
|
||||
@@ -42,16 +42,18 @@ import {
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
stationAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
curveShortfalls,
|
||||
curveSummary,
|
||||
flattenServerPlan,
|
||||
shortfallCrossed,
|
||||
type CurveLock,
|
||||
} from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
@@ -68,6 +70,8 @@ const NODE_HIT_PX = 9;
|
||||
const SEGMENT_HIT_PX = 12;
|
||||
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
|
||||
const CONTOUR_HIT_PX = 6;
|
||||
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
||||
const STATION_HIT_PX = 11;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -96,7 +100,9 @@ export async function openRouteEditModal(
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
||||
노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
노드 오른쪽 클릭 = 삭제 · <b>측점 눈금 클릭 = 횡단 미리보기</b> ·
|
||||
<b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
|
||||
가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
@@ -134,6 +140,9 @@ export async function openRouteEditModal(
|
||||
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
|
||||
let nodeInfo: EditedNode[] = [];
|
||||
let minRadiusM = 0;
|
||||
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
|
||||
let limitRadiusM = 0;
|
||||
let limitArcM = 0;
|
||||
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
|
||||
let curveInfo: EditedCurve[] = [];
|
||||
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
|
||||
@@ -154,6 +163,34 @@ export async function openRouteEditModal(
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
let pickedContour = -1;
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
bounds: () => canvas.getBoundingClientRect(),
|
||||
request: () => ({
|
||||
vertices: planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
}),
|
||||
});
|
||||
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
line: () => (plannedLine.length ? plannedLine : planned),
|
||||
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
status.textContent = `${routeHead()} — ${measure.hint()}`;
|
||||
draw();
|
||||
},
|
||||
});
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -169,6 +206,7 @@ export async function openRouteEditModal(
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
crossPreview.destroy();
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -233,6 +271,7 @@ export async function openRouteEditModal(
|
||||
otherSheets,
|
||||
pickedContour,
|
||||
contourStepM: contourStepM(),
|
||||
measure: measure.points(),
|
||||
expected,
|
||||
plannedLine,
|
||||
planned,
|
||||
@@ -284,6 +323,8 @@ export async function openRouteEditModal(
|
||||
function markEdited(): void {
|
||||
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
|
||||
applyArcLocks(planned, curveLock, curveArc, curveRadius);
|
||||
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
|
||||
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
|
||||
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
||||
plannedLine = built.vertices;
|
||||
curveInfo = built.curves;
|
||||
@@ -316,86 +357,26 @@ export async function openRouteEditModal(
|
||||
fresh: nodeInfo.length === 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onLock: (lock) => {
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
const curveBar = createCurveBar({
|
||||
canvas,
|
||||
state: () => ({
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveArc,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
}),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function syncCurveBar(): void {
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = toScreen(planned[picked]);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
toScreen(pickedCurve.start),
|
||||
toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
});
|
||||
}
|
||||
const curveLabelBox = curveBar.label;
|
||||
const syncCurveBar = curveBar.sync;
|
||||
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
@@ -440,6 +421,11 @@ export async function openRouteEditModal(
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
if (event.shiftKey) {
|
||||
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
|
||||
void measure.pick(px, py);
|
||||
return;
|
||||
}
|
||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
||||
@@ -454,6 +440,23 @@ export async function openRouteEditModal(
|
||||
picked = dragHandle.node;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (
|
||||
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
|
||||
(() => {
|
||||
const chainage = stationAtScreen(
|
||||
plannedLine.length ? plannedLine : planned,
|
||||
toScreen,
|
||||
stationIntervalM,
|
||||
px,
|
||||
py,
|
||||
STATION_HIT_PX,
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
void crossPreview.open(chainage);
|
||||
return true;
|
||||
})()
|
||||
) {
|
||||
/* 횡단 창이 떴다 — 더 집지 않는다. */
|
||||
} else if (contours) {
|
||||
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
|
||||
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
|
||||
@@ -477,7 +480,8 @@ export async function openRouteEditModal(
|
||||
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
|
||||
if (moved) {
|
||||
planned[node] = moved.apex;
|
||||
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
||||
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
|
||||
curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100);
|
||||
curveOn[node] = true;
|
||||
picked = node;
|
||||
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
|
||||
@@ -491,9 +495,23 @@ export async function openRouteEditModal(
|
||||
return;
|
||||
}
|
||||
if (dragNode >= 0) {
|
||||
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
|
||||
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
|
||||
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
|
||||
const previous = planned[dragNode];
|
||||
dragMoved = true;
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
|
||||
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
|
||||
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
|
||||
planned[dragNode] = previous;
|
||||
markEdited();
|
||||
status.textContent =
|
||||
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
|
||||
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
||||
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
|
||||
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
|
||||
@@ -563,54 +581,13 @@ export async function openRouteEditModal(
|
||||
draw,
|
||||
});
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
|
||||
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
|
||||
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
|
||||
const message = busy.querySelector("span")!;
|
||||
const started = Date.now();
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
close();
|
||||
await onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
bindRouteApply({
|
||||
overlay,
|
||||
busy,
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
close,
|
||||
onApplied,
|
||||
});
|
||||
|
||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
||||
@@ -626,6 +603,8 @@ export async function openRouteEditModal(
|
||||
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
||||
const nodes = plan.nodes ?? [];
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
limitRadiusM = plan.limit_radius_m ?? 0;
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
planned = flat.planned;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Apply.ts
|
||||
* 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시.
|
||||
*
|
||||
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
|
||||
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
|
||||
* 멈춘 것인지 도는 것인지 알 수 있게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
export interface RouteApplyParams {
|
||||
overlay: HTMLElement;
|
||||
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
|
||||
busy: HTMLElement;
|
||||
projectId: string;
|
||||
/** 지금 편집값 — 누른 순간에 읽는다. */
|
||||
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
|
||||
close: () => void;
|
||||
onApplied: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const { overlay, busy, projectId } = params;
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
|
||||
// 87.3 · 90.0 · 93.9 · 95.4초).
|
||||
const message = busy.querySelector("span")!;
|
||||
const started = Date.now();
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
params.close();
|
||||
await params.onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
const { planned, curveOn, curveRadius } = params.nodes();
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross.ts
|
||||
* 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧).
|
||||
*
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
||||
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
||||
*
|
||||
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의
|
||||
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
|
||||
*
|
||||
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 그림 가장자리 여백(px). */
|
||||
const PAD = 28;
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
projectId: string;
|
||||
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
|
||||
bounds: () => DOMRect;
|
||||
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
|
||||
request: () => {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__cross";
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__cross-head">
|
||||
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong>
|
||||
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
|
||||
<div class="b05-routeedit__cross-foot"></div>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
|
||||
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
|
||||
root.hidden = true;
|
||||
});
|
||||
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
|
||||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||||
root.addEventListener(type, (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
|
||||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||
head.addEventListener("pointerdown", (event) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
|
||||
head.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
if (!dragFrom) return;
|
||||
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
|
||||
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
|
||||
});
|
||||
const stopDrag = (event: PointerEvent): void => {
|
||||
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
|
||||
dragFrom = null;
|
||||
};
|
||||
head.addEventListener("pointerup", stopDrag);
|
||||
head.addEventListener("pointercancel", stopDrag);
|
||||
|
||||
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
|
||||
let asked = -1;
|
||||
|
||||
return {
|
||||
async open(chainageM) {
|
||||
asked = chainageM;
|
||||
root.hidden = false;
|
||||
if (!root.style.left) {
|
||||
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
|
||||
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
|
||||
const box = params.bounds();
|
||||
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
|
||||
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
|
||||
}
|
||||
title.textContent = "횡단 미리보기 — 읽는 중…";
|
||||
foot.textContent = "";
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let preview: CrossPreviewResponse;
|
||||
try {
|
||||
preview = await fetchCrossPreview(params.projectId, {
|
||||
...params.request(),
|
||||
chainage_m: chainageM,
|
||||
});
|
||||
} catch (error) {
|
||||
if (asked !== chainageM) return;
|
||||
title.textContent = "횡단 미리보기";
|
||||
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
|
||||
return;
|
||||
}
|
||||
if (asked !== chainageM || root.hidden) return;
|
||||
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
|
||||
drawCross(context, canvas, preview);
|
||||
foot.textContent = summarize(preview);
|
||||
},
|
||||
destroy() {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
|
||||
function summarize(preview: CrossPreviewResponse): string {
|
||||
const design = preview.design;
|
||||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||||
const lengths = fillSlopeLengths({
|
||||
samples: preview.samples,
|
||||
design,
|
||||
} as unknown as CrossSection);
|
||||
const sides = (["left", "right"] as const)
|
||||
.filter((side) => lengths[side] !== null)
|
||||
.map((side) => {
|
||||
const value = lengths[side]!;
|
||||
const label = side === "left" ? "좌" : "우";
|
||||
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
||||
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
|
||||
});
|
||||
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
|
||||
return (
|
||||
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡` +
|
||||
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
|
||||
);
|
||||
}
|
||||
|
||||
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
|
||||
function drawCross(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
preview: CrossPreviewResponse,
|
||||
): void {
|
||||
const ground = preview.samples
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
|
||||
const design = (preview.design?.design_line ?? []).map(
|
||||
(point) => [point.offset_m, point.elevation_m] as [number, number],
|
||||
);
|
||||
const all = [...ground, ...design];
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (all.length < 2) return;
|
||||
|
||||
const offsets = all.map((point) => point[0]);
|
||||
const heights = all.map((point) => point[1]);
|
||||
const minOffset = Math.min(...offsets);
|
||||
const maxOffset = Math.max(...offsets);
|
||||
const minZ = Math.min(...heights);
|
||||
const maxZ = Math.max(...heights);
|
||||
const spanX = maxOffset - minOffset || 1;
|
||||
const spanZ = maxZ - minZ || 1;
|
||||
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||||
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||||
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
|
||||
const centerOffset = (minOffset + maxOffset) / 2;
|
||||
const centerZ = (minZ + maxZ) / 2;
|
||||
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
|
||||
const toScreen = (point: [number, number]): [number, number] => [
|
||||
canvas.width / 2 + (centerOffset - point[0]) * scale,
|
||||
canvas.height / 2 + (centerZ - point[1]) * scale,
|
||||
];
|
||||
|
||||
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
|
||||
if (points.length < 2) return;
|
||||
context.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const [x, y] = toScreen(point);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||||
const [centerX] = toScreen([0, centerZ]);
|
||||
context.save();
|
||||
context.setLineDash([4, 4]);
|
||||
context.strokeStyle = "rgba(148,163,184,0.7)";
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(centerX, PAD / 2);
|
||||
context.lineTo(centerX, canvas.height - PAD / 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
|
||||
stroke(ground, "#94a3b8", 1.6); // 원지반
|
||||
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
|
||||
|
||||
context.font = "11px system-ui, sans-serif";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "#94a3b8";
|
||||
context.textAlign = "left";
|
||||
context.fillText("원지반", PAD, 6);
|
||||
context.fillStyle = "#f97316";
|
||||
context.textAlign = "right";
|
||||
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
|
||||
context.fillStyle = "#94a3b8";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "bottom";
|
||||
context.fillText(
|
||||
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
||||
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
||||
canvas.width / 2,
|
||||
canvas.height - 4,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_CurveBar.ts
|
||||
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
|
||||
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
||||
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
|
||||
*
|
||||
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
|
||||
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
type CurveLabel,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
|
||||
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
||||
export interface CurveBarState {
|
||||
picked: number;
|
||||
planned: Vertex[];
|
||||
nodeInfo: EditedNode[];
|
||||
curveInfo: EditedCurve[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
}
|
||||
|
||||
export interface CurveBarParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
state: () => CurveBarState;
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
||||
applyEdit: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CurveBar {
|
||||
label: CurveLabel;
|
||||
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const label = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
const { picked, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
params.applyEdit(
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
|
||||
);
|
||||
},
|
||||
onLock: (lock) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
params.applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
const { picked, curveOn } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function sync(): void {
|
||||
const {
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
} = params.state();
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
label.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = params.toScreen(planned[picked]);
|
||||
label.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
params.toScreen(pickedCurve.start),
|
||||
params.toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
});
|
||||
}
|
||||
|
||||
return { label, sync };
|
||||
}
|
||||
@@ -48,6 +48,75 @@ export function applyArcLocks(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **하한을 지키도록 지정 반지름을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
|
||||
*
|
||||
* L = R·Δ 이므로 「곡선 길이 하한」은 그 자리에서 「반지름 하한 L/Δ」과 같은 말이다. 두 하한
|
||||
* 중 큰 쪽으로 올린다. **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을
|
||||
* 쓰고 있고, 여기서 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
|
||||
*/
|
||||
export function applyCurveLimits(
|
||||
planned: Vertex[],
|
||||
curveOn: ReadonlyArray<boolean>,
|
||||
curveRadius: Array<number | null>,
|
||||
limitRadiusM: number,
|
||||
limitArcM: number,
|
||||
): void {
|
||||
if (limitRadiusM <= 0 && limitArcM <= 0) return;
|
||||
for (let seat = 1; seat < planned.length - 1; seat += 1) {
|
||||
if (curveOn[seat] === false) continue;
|
||||
const current = curveRadius[seat];
|
||||
if (current === null || current === undefined) continue;
|
||||
const deflection = deflectionRad(
|
||||
innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]),
|
||||
);
|
||||
const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0;
|
||||
const floor = Math.max(limitRadiusM, byArc);
|
||||
if (floor > 0 && current < floor) curveRadius[seat] = floor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드마다 하한을 **얼마나 밑돌고 있나**(m). 다 지키고 있으면 0.
|
||||
*
|
||||
* 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면
|
||||
* 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미
|
||||
* 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12).
|
||||
*
|
||||
* ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로
|
||||
* 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로
|
||||
* 지나갔다). 자리마다 따로 견준다.
|
||||
*/
|
||||
export function curveShortfalls(
|
||||
nodes: ReadonlyArray<EditedNode>,
|
||||
limitRadiusM: number,
|
||||
limitArcM: number,
|
||||
): number[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0;
|
||||
let short = 0;
|
||||
if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m);
|
||||
if (limitArcM > 0) {
|
||||
short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg));
|
||||
}
|
||||
return Math.max(0, short);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기)
|
||||
* 안 따진다.
|
||||
*
|
||||
* ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** —
|
||||
* 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12
|
||||
* 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는
|
||||
* 것만** 막는다.
|
||||
*/
|
||||
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
|
||||
if (before.length !== after.length) return false;
|
||||
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
|
||||
}
|
||||
|
||||
export interface CurveSummaryInput {
|
||||
nodeCount: number;
|
||||
curveOn: boolean[];
|
||||
|
||||
@@ -181,6 +181,98 @@ export function segmentAtScreen(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
|
||||
export interface RoutePointHit {
|
||||
/** 사업지 좌표(m). */
|
||||
point: [number, number];
|
||||
/** 시점에서 노선을 따라간 거리(m). */
|
||||
chainageM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선(그려지는 폴리라인) 위에서 **클릭에 가장 가까운 점**과 그 누가거리. 멀면 null.
|
||||
*
|
||||
* 직선·곡선을 가리지 않는다(계획서 0-9 ⑤) — 원호도 이미 정점으로 펴져 있어 같은 선분 훑기로
|
||||
* 잡힌다. 누가거리는 선분 길이를 누적해 구하므로 노선 길이 표기와 같은 값을 본다.
|
||||
*/
|
||||
export function routePointAtScreen(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): RoutePointHit | null {
|
||||
let best: RoutePointHit | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let travelled = 0;
|
||||
for (let index = 0; index < line.length - 1; index += 1) {
|
||||
const from = line[index];
|
||||
const to = line[index + 1];
|
||||
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
||||
const [ax, ay] = toScreen(from);
|
||||
const [bx, by] = toScreen(to);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = {
|
||||
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
|
||||
chainageM: travelled + segmentM * t,
|
||||
};
|
||||
}
|
||||
travelled += segmentM;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧).
|
||||
*
|
||||
* 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격
|
||||
* 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다.
|
||||
*/
|
||||
export function stationAtScreen(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
intervalM: number,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): number | null {
|
||||
if (line.length < 2 || !(intervalM > 0)) return null;
|
||||
const cumulative: number[] = [0];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
cumulative.push(
|
||||
cumulative[index - 1] +
|
||||
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
|
||||
);
|
||||
}
|
||||
const total = cumulative[cumulative.length - 1];
|
||||
let best: number | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += intervalM) {
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = line[cursor - 1];
|
||||
const front = line[cursor];
|
||||
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
||||
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
||||
const [x, y] = toScreen([
|
||||
back[0] + (front[0] - back[0]) * ratio,
|
||||
back[1] + (front[1] - back[1]) * ratio,
|
||||
]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = chainage;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface CurveLabelState {
|
||||
arcLengthShown: number | null;
|
||||
lock: CurveLock;
|
||||
innerAngleDeg: number | null;
|
||||
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM?: number;
|
||||
limitArcM?: number;
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
@@ -148,15 +151,25 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||||
let manual: [number, number] | null = null;
|
||||
let anchor: [number, number] = [0, 0];
|
||||
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
|
||||
let limitRadius = 0;
|
||||
let limitArc = 0;
|
||||
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
|
||||
let limit: CurveLabelState["bounds"];
|
||||
|
||||
const numberOf = (input: HTMLInputElement): number | null => {
|
||||
/** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다**
|
||||
* (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */
|
||||
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
|
||||
const value = Number(input.value);
|
||||
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
|
||||
if (floor > 0 && value < floor) {
|
||||
input.value = String(floor);
|
||||
return floor;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
||||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
||||
@@ -242,6 +255,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
curveOn = state.curveOn;
|
||||
lock = state.lock;
|
||||
limit = state.bounds;
|
||||
limitRadius = state.limitRadiusM ?? 0;
|
||||
limitArc = state.limitArcM ?? 0;
|
||||
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
|
||||
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
|
||||
arc.min = limitArc > 0 ? String(limitArc) : "1";
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
@@ -258,8 +276,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
const inner = state.innerAngleDeg;
|
||||
const held =
|
||||
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
|
||||
const floors = [
|
||||
limitRadius > 0 ? `R ≥ ${limitRadius}m` : "",
|
||||
limitArc > 0 ? `L ≥ ${limitArc}m` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
info.textContent = state.curveOn
|
||||
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
|
||||
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Measure.ts
|
||||
* 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
|
||||
*
|
||||
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
|
||||
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다.
|
||||
*
|
||||
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
||||
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
||||
* 끄는 동안에는 한 번도 부르지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
||||
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
||||
const MEASURE_HIT_PX = 14;
|
||||
|
||||
interface MeasurePoint extends RoutePointHit {
|
||||
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
|
||||
z: number | null;
|
||||
}
|
||||
|
||||
export interface MeasureToolParams {
|
||||
projectId: string;
|
||||
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
|
||||
stationIntervalM: number;
|
||||
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
|
||||
line: () => Vertex[];
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
|
||||
isClosed: () => boolean;
|
||||
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export interface MeasureTool {
|
||||
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
|
||||
points: () => Vertex[];
|
||||
/** 상태줄에 낼 한 줄. */
|
||||
hint: () => string;
|
||||
/** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */
|
||||
pick: (px: number, py: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
|
||||
let picked: MeasurePoint[] = [];
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다.";
|
||||
const first = picked[0];
|
||||
if (picked.length === 1) {
|
||||
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
const second = picked[1];
|
||||
const span = Math.abs(second.chainageM - first.chainageM);
|
||||
const head =
|
||||
`구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (first.z === null || second.z === null || span <= 1e-6) {
|
||||
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
}
|
||||
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
||||
const rise = second.z - first.z;
|
||||
return (
|
||||
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
|
||||
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
points: () => picked.map((entry) => entry.point),
|
||||
hint,
|
||||
async pick(px, py) {
|
||||
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
if (!hit) {
|
||||
picked = []; // 노선을 빗나가면 재던 것을 접는다.
|
||||
params.onChange();
|
||||
return;
|
||||
}
|
||||
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
params.onChange();
|
||||
if (picked.length < 2) return;
|
||||
const asked = picked;
|
||||
try {
|
||||
const heights = await fetchRouteElevations(
|
||||
params.projectId,
|
||||
asked.map((entry) => entry.point),
|
||||
);
|
||||
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
|
||||
asked.forEach((entry, index) => {
|
||||
entry.z = heights[index] ?? null;
|
||||
});
|
||||
} catch {
|
||||
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
|
||||
}
|
||||
params.onChange();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,8 @@ const CONTOUR_BAND_M = 300;
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
|
||||
const OUTWARD_PX = 26;
|
||||
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
|
||||
const MEASURE_COLOR = "#22c55e";
|
||||
|
||||
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
|
||||
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
|
||||
@@ -77,6 +79,8 @@ export interface RouteEditScene {
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<Vertex>;
|
||||
}
|
||||
|
||||
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
|
||||
@@ -201,6 +205,67 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.restore();
|
||||
|
||||
drawStationMarks(context, scene, line);
|
||||
drawMeasureMarks(context, scene, line);
|
||||
}
|
||||
|
||||
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
|
||||
function drawMeasureMarks(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
): void {
|
||||
if (scene.measure.length === 0) return;
|
||||
context.save();
|
||||
if (scene.measure.length >= 2) {
|
||||
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
|
||||
if (span.length >= 2) {
|
||||
context.strokeStyle = MEASURE_COLOR;
|
||||
context.lineWidth = 4;
|
||||
context.beginPath();
|
||||
span.forEach((vertex, index) => {
|
||||
const [x, y] = scene.toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
context.lineWidth = 2.4;
|
||||
context.strokeStyle = MEASURE_COLOR;
|
||||
context.font = "bold 11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
scene.measure.forEach((vertex, index) => {
|
||||
const [x, y] = scene.toScreen(vertex);
|
||||
context.fillStyle = "rgba(255,255,255,0.95)";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 7, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#14532d";
|
||||
context.fillText(index === 0 ? "a" : "b", x, y);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */
|
||||
function spanBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
|
||||
const nearest = (target: Vertex): number => {
|
||||
let best = 0;
|
||||
let bestDistance = Infinity;
|
||||
line.forEach((vertex, index) => {
|
||||
const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const start = nearest(from);
|
||||
const end = nearest(to);
|
||||
const [low, high] = start <= end ? [start, end] : [end, start];
|
||||
return [from, ...line.slice(low, high + 1), to];
|
||||
}
|
||||
|
||||
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
|
||||
|
||||
@@ -241,3 +241,53 @@
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ────────────────────────────────
|
||||
곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden`
|
||||
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */
|
||||
.b05-routeedit__cross {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
width: 452px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8, 8px);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-close {
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-foot {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
|
||||
}
|
||||
|
||||
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
|
||||
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
|
||||
// 구분이 안 되는 경계를 표시한다.
|
||||
const edges = design.carriageway_edges;
|
||||
if (edges) {
|
||||
for (const edge of [edges.left, edges.right]) {
|
||||
const cx = x(edge.offset_m);
|
||||
const cy = toDisplayY(edge.elevation_m);
|
||||
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
|
||||
const cx = x(offsetM);
|
||||
const cy = toDisplayY(elevationM);
|
||||
const tick = document.createElementNS(SVG_NS, "line");
|
||||
tick.setAttribute("x1", String(cx));
|
||||
tick.setAttribute("y1", String(cy - 3.6));
|
||||
tick.setAttribute("y1", String(cy - half));
|
||||
tick.setAttribute("x2", String(cx));
|
||||
tick.setAttribute("y2", String(cy + 3.6));
|
||||
tick.setAttribute("class", "b06-chart__carriageway-tick");
|
||||
tick.setAttribute("y2", String(cy + half));
|
||||
tick.setAttribute("class", className);
|
||||
svg.append(tick);
|
||||
};
|
||||
for (const edge of [edges.left, edges.right]) {
|
||||
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
|
||||
}
|
||||
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
|
||||
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
|
||||
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
|
||||
const roadEdges = design.road_edges;
|
||||
if (roadEdges) {
|
||||
for (const edge of [roadEdges.left, roadEdges.right]) {
|
||||
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
|
||||
}
|
||||
}
|
||||
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
|
||||
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
|
||||
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
|
||||
const standardWidth = design.carriageway_standard_width_m;
|
||||
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
|
||||
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
|
||||
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
|
||||
label.setAttribute("class", "b06-chart__carriageway-label");
|
||||
label.textContent =
|
||||
const widthText =
|
||||
widened > 0.001 && typeof standardWidth === "number"
|
||||
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
|
||||
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
|
||||
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
|
||||
label.textContent =
|
||||
shoulderLeft != null && shoulderRight != null
|
||||
? `${widthText} · 노견 ${
|
||||
Math.abs(shoulderLeft - shoulderRight) < 0.005
|
||||
? `${shoulderLeft.toFixed(2)}m`
|
||||
: `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
|
||||
}`
|
||||
: widthText;
|
||||
svg.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,14 @@
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
/* 노견 바깥 끝 틱(2026-09-12) — 차도 틱보다 짧고 옅다. 차도 틱과 이 틱 사이가 노견이라,
|
||||
노면이 넓어졌을 때 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
|
||||
.b06-chart__shoulder-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
|
||||
.b06-chart__carriageway-label {
|
||||
fill: var(--color-royal-amethyst);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
drawBaseDataTab,
|
||||
drawFactorChoices,
|
||||
@@ -175,6 +176,14 @@ function injectStyles(): void {
|
||||
style.textContent = `
|
||||
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
|
||||
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
|
||||
/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담).
|
||||
.ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */
|
||||
.b09-panel__group.ui-sidebar-section {
|
||||
margin: 0;
|
||||
padding: calc(var(--spacing-8) + var(--spacing-4));
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
.b09-panel__legend {
|
||||
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
|
||||
color: var(--color-text-secondary); text-transform: uppercase;
|
||||
@@ -184,7 +193,6 @@ function injectStyles(): void {
|
||||
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
|
||||
border-bottom: 1px solid var(--color-border); padding: 2px 0;
|
||||
}
|
||||
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
|
||||
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
|
||||
/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */
|
||||
.b09-hint--warn { color: var(--color-warning-text, #8a5a00); }
|
||||
@@ -483,10 +491,10 @@ function buildSidePanel(
|
||||
legendKey: keyof typeof ui_locales,
|
||||
fields: Array<[keyof CostFormState, keyof typeof ui_locales]>,
|
||||
): void => {
|
||||
const group = document.createElement("div");
|
||||
group.className = "b09-panel__group";
|
||||
const group = document.createElement("section");
|
||||
group.className = "b09-panel__group ui-collapsible ui-sidebar-section";
|
||||
const legend = document.createElement("span");
|
||||
legend.className = "b09-panel__legend";
|
||||
legend.className = "b09-panel__legend ui-collapsible__title";
|
||||
legend.textContent = L(legendKey);
|
||||
group.append(legend);
|
||||
for (const [field, labelKey] of fields) {
|
||||
@@ -512,10 +520,10 @@ function buildSidePanel(
|
||||
]);
|
||||
|
||||
// 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다.
|
||||
const rateGroup = document.createElement("div");
|
||||
rateGroup.className = "b09-panel__group";
|
||||
const rateGroup = document.createElement("section");
|
||||
rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
|
||||
const rateLegend = document.createElement("span");
|
||||
rateLegend.className = "b09-panel__legend";
|
||||
rateLegend.className = "b09-panel__legend ui-collapsible__title";
|
||||
rateLegend.textContent = L("B09_Estimation_Group_RateVersion");
|
||||
const rateVersionBox = document.createElement("div");
|
||||
rateGroup.append(rateLegend, rateVersionBox);
|
||||
@@ -532,10 +540,10 @@ function buildSidePanel(
|
||||
]);
|
||||
|
||||
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
|
||||
const quantityGroup = document.createElement("div");
|
||||
quantityGroup.className = "b09-panel__group";
|
||||
const quantityGroup = document.createElement("section");
|
||||
quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
|
||||
const quantityLegend = document.createElement("span");
|
||||
quantityLegend.className = "b09-panel__legend";
|
||||
quantityLegend.className = "b09-panel__legend ui-collapsible__title";
|
||||
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
|
||||
const quantityLabel = document.createElement("label");
|
||||
quantityLabel.className = "ui-field__label";
|
||||
@@ -555,7 +563,8 @@ function buildSidePanel(
|
||||
root.append(hintBox);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b09-panel__actions";
|
||||
// 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다.
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("B09_Estimation_Btn_Recalc"),
|
||||
@@ -569,6 +578,9 @@ function buildSidePanel(
|
||||
);
|
||||
root.append(actions);
|
||||
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다.
|
||||
attachCollapsible(root);
|
||||
|
||||
return { root, rateVersionBox, hintBox };
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,31 @@ FOREST_ROAD_PROFILE_CRITERIA = {
|
||||
# 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만**
|
||||
# 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정).
|
||||
"hairpin_min_radius_m": 10.0,
|
||||
# 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다
|
||||
# (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고
|
||||
# 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어
|
||||
# 곡선이 아예 안 그려진다.
|
||||
# · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다.
|
||||
# · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 —
|
||||
# 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다.
|
||||
# ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드
|
||||
# trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다.
|
||||
"plan_radius_limit_by_grade_m": {
|
||||
"main": None,
|
||||
"trunk": None,
|
||||
"fire": None,
|
||||
"work": 0.0,
|
||||
"branch": None,
|
||||
},
|
||||
# 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다.
|
||||
# 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정).
|
||||
"plan_curve_length_limit_by_grade_m": {
|
||||
"main": 0.0,
|
||||
"trunk": 0.0,
|
||||
"fire": 0.0,
|
||||
"work": 0.0,
|
||||
"branch": 0.0,
|
||||
},
|
||||
# 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
|
||||
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
|
||||
# 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른
|
||||
|
||||
@@ -148,3 +148,64 @@ def test_widening_without_curve_stays_zero() -> None:
|
||||
widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3)
|
||||
assert widenings == [0.0, 0.0, 0.0]
|
||||
assert sides == [None, None, None]
|
||||
|
||||
|
||||
def test_design_curve_spans_from_curve_table() -> None:
|
||||
"""설계 곡선표의 시·종점이 노선 누가거리로 바뀌고, 회전 방향으로 바깥쪽이 갈린다."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_spans
|
||||
|
||||
# ㄱ자 노선 — (0,0) → (100,0) → (100,100). 교점 (100,0) 에서 좌회전.
|
||||
line = np.array([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [100.0, 100.0, 0.0]])
|
||||
chainage = np.array([0.0, 100.0, 200.0])
|
||||
curves = [
|
||||
{
|
||||
"start": [90.0, 0.0],
|
||||
"apex": [100.0, 0.0],
|
||||
"end": [100.0, 10.0],
|
||||
"radius_m": 12.0,
|
||||
}
|
||||
]
|
||||
spans = _design_curve_spans(line, chainage, curves)
|
||||
assert len(spans) == 1
|
||||
start, end, radius, side = spans[0]
|
||||
assert abs(start - 90.0) < 1e-6 and abs(end - 110.0) < 1e-6
|
||||
assert radius == 12.0
|
||||
# 좌회전이면 안쪽이 좌측이라 바깥은 우측이다.
|
||||
assert side == "right"
|
||||
# 노선에서 멀리 떨어진 곡선은 버린다.
|
||||
assert _design_curve_spans(line, chainage, [{**curves[0], "start": [90.0, 500.0]}]) == []
|
||||
|
||||
|
||||
def test_design_curve_widening_is_one_value_inside_the_curve() -> None:
|
||||
"""같은 곡선 안 측점은 설계 반경의 표값 하나를 쓰고, 앞뒤 10m 만 이어 준다."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
|
||||
|
||||
# 곡선 100~140m, 설계 반경 16m(표값 1.5m). 측점 5m 간격.
|
||||
spans = [(100.0, 140.0, 16.0, "left")]
|
||||
chainage = np.arange(80.0, 165.0, 5.0)
|
||||
radii, sides, widenings = _design_curve_widenings(chainage, spans)
|
||||
table = dict(zip(chainage.tolist(), widenings, strict=True))
|
||||
# 곡선 안은 어디서나 같은 값 — 종전에는 측점마다 반경을 다시 재 값이 갈렸다.
|
||||
for station in (100.0, 110.0, 120.0, 130.0, 140.0):
|
||||
assert table[station] == 1.5, station
|
||||
# 앞뒤 10m 는 0 으로 잇는다.
|
||||
assert table[95.0] == 0.75 and table[145.0] == 0.75
|
||||
assert table[90.0] == 0.0 and table[150.0] == 0.0
|
||||
# 반경은 곡선 안에서만 남고, 테이퍼·직선 자리는 비어 있다.
|
||||
by_index = dict(zip(chainage.tolist(), radii, strict=True))
|
||||
assert by_index[120.0] == 16.0
|
||||
assert by_index[95.0] is None and by_index[90.0] is None
|
||||
# 방향은 곡선 것을 따라간다.
|
||||
assert sides[chainage.tolist().index(95.0)] == "left"
|
||||
|
||||
|
||||
def test_design_curve_widening_takes_the_wider_of_overlapping_curves() -> None:
|
||||
"""곡선이 겹치거나 붙어 있으면 확폭이 큰 쪽을 따른다."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
|
||||
|
||||
spans = [(100.0, 120.0, 35.0, "left"), (118.0, 140.0, 12.0, "right")]
|
||||
chainage = np.array([110.0, 119.0, 130.0])
|
||||
_, sides, widenings = _design_curve_widenings(chainage, spans)
|
||||
assert widenings[0] == 0.5 # R=35 → 0.5m
|
||||
assert widenings[1] == 2.25 and sides[1] == "right" # 겹친 자리는 급한 곡선 값
|
||||
assert widenings[2] == 2.25
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험.
|
||||
|
||||
기본값(`legal_plan_radius_min_m`)과 **못 넘는 하한**(`plan_radius_limit_m`)은 다른 값이다
|
||||
(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 작업임도의 하한 0 이 곧 반지름 0 이 되어
|
||||
곡선이 아예 안 그려지므로, 갈라져 있다는 것 자체를 시험으로 못박는다.
|
||||
"""
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
legal_plan_radius_min_m,
|
||||
plan_curve_length_limit_m,
|
||||
plan_radius_limit_m,
|
||||
resolve_design_speed,
|
||||
)
|
||||
|
||||
|
||||
def _default(grade_class: str, terrain: str) -> float:
|
||||
return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain)
|
||||
|
||||
|
||||
def test_작업임도는_하한이_없다():
|
||||
"""별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다."""
|
||||
assert plan_radius_limit_m("work", None, "normal") == 0.0
|
||||
assert plan_radius_limit_m("work", None, "special") == 0.0
|
||||
|
||||
|
||||
def test_작업임도도_기본_반지름은_그대로다():
|
||||
"""하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다."""
|
||||
assert _default("work", "normal") > 0
|
||||
|
||||
|
||||
def test_간선_산불진화는_법정표가_곧_하한이다():
|
||||
for grade_class in ("main", "trunk", "fire"):
|
||||
for terrain in ("normal", "special"):
|
||||
assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain)
|
||||
|
||||
|
||||
def test_모르는_임도종류는_법정표로_떨어진다():
|
||||
"""칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽)."""
|
||||
assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal")
|
||||
|
||||
|
||||
def test_곡선길이_하한은_아직_전부_0이다():
|
||||
"""법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다."""
|
||||
for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"):
|
||||
assert plan_curve_length_limit_m(grade_class) == 0.0
|
||||
Reference in New Issue
Block a user