diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
index 2a3aacbf..92d6b979 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
@@ -4,7 +4,7 @@ import {
type IrregularStation,
type IrregularStationsSection,
} from "./B05_wf2_Route_UI_IrregularStations";
-import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
+import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -218,18 +218,24 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const conditions = section("임도 기준·옵션");
conditions.root.classList.add("is-collapsed");
- const algorithm = document.createElement("select");
- algorithm.innerHTML =
- '';
- const gradeClass = document.createElement("select");
- gradeClass.innerHTML =
- '';
- const algorithmLabel = document.createElement("label");
- algorithmLabel.className = "b05-route__field";
- algorithmLabel.append(document.createTextNode("알고리즘"), algorithm);
- const gradeLabel = document.createElement("label");
- gradeLabel.className = "b05-route__field";
- gradeLabel.append(document.createTextNode("임도 등급"), gradeClass);
+ // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변.
+ const algorithmField = createSelectField({
+ label: "알고리즘",
+ options: [
+ { value: "dijkstra", text: "Dijkstra" },
+ { value: "ridge_valley", text: "능선·계곡" },
+ ],
+ });
+ const algorithm = algorithmField.select;
+ const gradeField = createSelectField({
+ label: "임도 등급",
+ options: [
+ { value: "trunk", text: "간선" },
+ { value: "branch", text: "지선" },
+ { value: "work", text: "작업" },
+ ],
+ });
+ const gradeClass = gradeField.select;
const minCurveRadius = numberField("최소 곡선반경 (m)");
const maxUphillGrade = numberField("오르막 경사 상한 (%)");
const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
@@ -248,7 +254,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
- conditions.body.append(algorithmLabel, gradeLabel, paved.wrapper, avoidPass.wrapper, details);
+ conditions.body.append(
+ algorithmField.root,
+ gradeField.root,
+ paved.wrapper,
+ avoidPass.wrapper,
+ details,
+ );
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
sectionOptions.root.classList.add("is-collapsed");
@@ -262,12 +274,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
);
const gradeLine = section("종단 설계 기준");
- const terrainType = document.createElement("select");
- terrainType.innerHTML =
- '';
- const terrainLabel = document.createElement("label");
- terrainLabel.className = "b05-route__field";
- terrainLabel.append(document.createTextNode("지형 구분"), terrainType);
+ const terrainField = createSelectField({
+ label: "지형 구분",
+ options: [
+ { value: "normal", text: "일반지형" },
+ { value: "special", text: "특수지형" },
+ ],
+ });
+ const terrainType = terrainField.select;
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
const maxGradePct = numberField("최대 종단기울기 (%)");
@@ -288,7 +302,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.wrapper,
endElevationOffset.wrapper,
);
- gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced);
+ gradeLine.body.append(terrainField.root, criteriaNote, gradeAdvanced);
// 공사 시작점 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css
index a1ff6d4e..b9cd8f16 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css
@@ -708,10 +708,7 @@
color: rgb(37 99 235);
}
-.b05-profile-table__irregular-col-cell.is-plan {
- color: var(--color-royal-amethyst, rgb(109 40 217));
- font-weight: var(--font-weight-medium);
-}
+/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */
/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */
.b05-profile-table__irregular-input {
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
index 9648419b..1f6c7fac 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
@@ -192,6 +192,10 @@ export interface CrossDesign {
/** 측구 형식(일반/L형). 양성(측구 없음)은 null. */
ditch_type: DitchType | null;
cut_slope_ratio: number;
+ /** 2단계 절토의 토사(상단) 경사비. 암 지반에서만 의미. */
+ soil_cut_slope_ratio?: number;
+ /** 암반 경계 기준 2단계 경사 적용 여부(엔진이 실제 적용했는지). */
+ two_stage_slope?: boolean;
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
@@ -234,6 +238,10 @@ export interface CrossDesignRequest {
ditch_type?: DitchType;
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
paved?: boolean;
+ /** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
+ rock_boundary_offset_m?: number | null;
+ /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
+ two_stage_slope?: boolean;
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
standard_cross_section?: StandardCrossSection;
}
@@ -334,3 +342,41 @@ export async function confirmSections(
body: JSON.stringify(body),
});
}
+
+/** 같은 회사에서 표준횡단 설계값을 불러올 수 있는 프로젝트 항목. */
+export interface CompanyStandardProject {
+ project_id: string;
+ name: string;
+}
+
+export interface CompanyStandardListResponse {
+ status: string;
+ projects: CompanyStandardProject[];
+}
+
+export interface CompanyStandardResponse {
+ status: string;
+ project_id: string;
+ standard_cross_section: StandardCrossSection;
+}
+
+/** 같은 회사에서 설계값을 불러올 수 있는 프로젝트 목록을 조회한다(회사 스코프). */
+export async function listCompanyStandards(
+ projectId: string,
+): Promise {
+ return requestJson(
+ `/projects/${projectId}/sections/company-standards`,
+ { method: "GET" },
+ );
+}
+
+/** 특정 프로젝트의 표준횡단 설계값을 미리보기용으로 조회한다(적용 전, 현재 값 불변). */
+export async function getCompanyStandard(
+ projectId: string,
+ sourceProjectId: string,
+): Promise {
+ return requestJson(
+ `/projects/${projectId}/sections/company-standards/${sourceProjectId}`,
+ { method: "GET" },
+ );
+}
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py
index eb3c5888..2a9b50d1 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py
@@ -15,6 +15,7 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
+from collections.abc import Callable
from typing import Any
from config.config_system import (
@@ -170,15 +171,28 @@ class _SectionGeometry:
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
+ ground_at: Callable[[float], float] | None = None,
+ soil_cut_ratio: float | None = None,
+ rock_boundary_offset_m: float | None = None,
+ two_stage_slope: bool = False,
) -> None:
half_road = group["road_width_m"] / 2.0
self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
- self.cut_ratio = max(group["cut_slope_ratio"], 1e-6)
+ self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
+ # 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
+ # 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
+ self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
+ self.two_stage = bool(
+ two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
+ )
+ self._ground_at = ground_at
+ self._rock_offset = rock_boundary_offset_m or 0.0
+ self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다.
self.has_ditch = section_mode != "both_fill"
self.ditch_type = ditch_type
@@ -186,6 +200,19 @@ class _SectionGeometry:
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
+ # 편절편성에서 계획고가 지반보다 많이 낮으면 성토측 지반이 노면 끝보다 높아
+ # 실제로는 양측 절토가 된다. 그 측 역할을 절토로 자동 전환한다(측구 위치는
+ # ditch_side 그대로 유지, 전환된 측도 2단 경사 대상). 양절·양성(사용자 명시)은
+ # 손대지 않는다.
+ if section_mode in ("left_cut", "right_cut") and ground_at is not None:
+ for side, edge in (("left", self.left_extent), ("right", -self.right_extent)):
+ role = self.left_role if side == "left" else self.right_role
+ if role == "fill" and ground_at(edge) > self.road_z(edge) + 1e-3:
+ if side == "left":
+ self.left_role = "cut"
+ else:
+ self.right_role = "cut"
+
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
@@ -228,6 +255,48 @@ class _SectionGeometry:
return abs(outer[0]), outer[1]
return edge_offset, edge_z
+ def _rock_boundary_z(self, side: str, dist: float) -> float:
+ """측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
+ signed = dist if side == "left" else -dist
+ assert self._ground_at is not None # two_stage일 때만 호출
+ return self._ground_at(signed) + self._rock_offset
+
+ def knee(self, side: str) -> tuple[float, float] | None:
+ """절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
+
+ 노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
+ 토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
+ None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
+ """
+ if not self.two_stage:
+ return None
+ if side in self._rock_knee:
+ return self._rock_knee[side]
+ start_dist, start_z = self._slope_start(side)
+ diff_prev = start_z - self._rock_boundary_z(side, start_dist)
+ result: tuple[float, float] | None
+ if diff_prev >= 0:
+ result = (start_dist, start_z) # 시작부터 토사(경계 위)
+ else:
+ result = None
+ step = 0.05
+ dist_prev = start_dist
+ dist = start_dist + step
+ while dist <= start_dist + 200.0:
+ z_rock = start_z + (dist - start_dist) / self.cut_ratio
+ diff = z_rock - self._rock_boundary_z(side, dist)
+ if diff >= 0:
+ span = diff - diff_prev
+ ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
+ knee_dist = dist_prev + (dist - dist_prev) * ratio
+ knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
+ result = (knee_dist, knee_z)
+ break
+ dist_prev, diff_prev = dist, diff
+ dist += step
+ self._rock_knee[side] = result
+ return result
+
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
@@ -253,17 +322,32 @@ class _SectionGeometry:
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
start_dist, start_z = self._slope_start(side)
- run = abs(offset_m) - start_dist
+ dist = abs(offset_m)
+ run = dist - start_dist
if role == "cut":
- slope_line = start_z + run / self.cut_ratio
+ knee = self.knee(side) if self.two_stage else None
+ if knee is not None:
+ knee_dist, knee_z = knee
+ if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
+ slope_line = start_z + (dist - start_dist) / self.cut_ratio
+ else: # 토사 구간(경계 위): 무릎에서 토사 경사로 완만
+ slope_line = knee_z + (dist - knee_dist) / self.soil_cut_ratio
+ else:
+ slope_line = start_z + run / self.cut_ratio
return min(slope_line, ground_m)
fill_line = start_z - run / self.fill_ratio
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
- """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록."""
+ """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
+ if self.two_stage:
+ for side in ("left", "right"):
+ role = self.left_role if side == "left" else self.right_role
+ knee = self.knee(side) if role == "cut" else None
+ if knee is not None:
+ points.append(knee[0] if side == "left" else -knee[0])
return points
@@ -277,6 +361,8 @@ def compute_cross_design(
ditch_type: str = "standard",
paved: bool = False,
standard: dict[str, Any] | None = None,
+ rock_boundary_offset_m: float | None = None,
+ two_stage_slope: bool = True,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -285,6 +371,8 @@ def compute_cross_design(
ditch_type: 일반(standard)/L형(l_type, 암 구간 전용).
paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형).
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
+ rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
+ two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
@@ -317,6 +405,12 @@ def compute_cross_design(
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
+ ground_at = _ground_interpolator(valid)
+ # 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
+ enable_two_stage = (
+ preset_key == "rock" and two_stage_slope and rock_boundary_offset_m is not None
+ )
+ soil_cut_ratio = _resolve_group("soil", standard)["cut_slope_ratio"]
geometry = _SectionGeometry(
design_elevation_m=design_elevation_m,
group=group,
@@ -324,8 +418,11 @@ def compute_cross_design(
ditch_side=resolved_ditch_side,
ditch_type=ditch_type,
cross_slope_pct=cross_slope_pct,
+ ground_at=ground_at,
+ soil_cut_ratio=soil_cut_ratio,
+ rock_boundary_offset_m=rock_boundary_offset_m,
+ two_stage_slope=enable_two_stage,
)
- ground_at = _ground_interpolator(valid)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
@@ -378,6 +475,8 @@ def compute_cross_design(
"ditch_side": resolved_ditch_side,
"ditch_type": ditch_type if geometry.has_ditch else None,
"cut_slope_ratio": round(geometry.cut_ratio, 4),
+ "soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4),
+ "two_stage_slope": bool(geometry.two_stage),
"fill_slope_ratio": round(geometry.fill_ratio, 4),
"roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4),
"carriageway_width_m": round(group["road_width_m"], 4),
@@ -403,6 +502,9 @@ def compute_cross_design(
}
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
+ # 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
+ if preset_key == "rock" and rock_boundary_offset_m is not None:
+ result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
return result
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py
index 7163aebe..522a4e6a 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py
@@ -86,6 +86,63 @@ async def get_latest_section_options(
return options if isinstance(options, dict) else None
+async def list_recent_company_projects(
+ connection: aiomysql.Connection, company_id: int, exclude_project_id: UUID
+) -> list[dict[str, Any]]:
+ """같은 회사의 최근 프로젝트 5개(최근 갱신순, 현재·삭제 프로젝트 제외).
+
+ 설계값 보유 여부와 무관하게 보여준다 — 선택 시 설계값 조회에서 없으면 안내한다.
+ """
+ async with connection.cursor(aiomysql.DictCursor) as cursor:
+ await cursor.execute(
+ """
+ SELECT p.id AS project_id, p.name AS name
+ FROM projects p
+ WHERE p.company_id = %s
+ AND p.id <> %s
+ AND p.deleted_at IS NULL
+ ORDER BY p.updated_at DESC
+ LIMIT 5
+ """,
+ (company_id, str(exclude_project_id)),
+ )
+ rows = await cursor.fetchall()
+ return [{"project_id": row["project_id"], "name": row["name"]} for row in rows]
+
+
+async def get_project_standard_cross_section(
+ connection: aiomysql.Connection, company_id: int, project_id: UUID
+) -> dict[str, Any] | None:
+ """회사 스코프로 특정 프로젝트의 표준횡단 설정값을 반환한다(타 회사 접근 차단).
+
+ company_id 조건으로 남의 회사 프로젝트 값은 조회되지 않는다(권한 강제).
+ """
+ async with connection.cursor() as cursor:
+ await cursor.execute(
+ """
+ SELECT l.data
+ FROM longitudinal_sections l
+ JOIN projects p ON p.id = l.project_id
+ WHERE l.project_id = %s
+ AND p.company_id = %s
+ AND p.deleted_at IS NULL
+ AND JSON_EXTRACT(l.data, '$.options.standard_cross_section') IS NOT NULL
+ ORDER BY l.id DESC
+ LIMIT 1
+ """,
+ (str(project_id), company_id),
+ )
+ row = await cursor.fetchone()
+ if not row or not row[0]:
+ return None
+ data = row[0]
+ if isinstance(data, str):
+ data = json.loads(data)
+ options = data.get("options") if isinstance(data, dict) else None
+ standard = options.get("standard_cross_section") if isinstance(options, dict) else None
+ return standard if isinstance(standard, dict) else None
+
+
async def get_latest_grade_options(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
@@ -274,11 +331,14 @@ async def update_cross_section_design(
route_id: int,
chainage_m: float,
design: dict[str, Any],
+ project_id: UUID | None = None,
) -> bool:
"""측점 하나의 data.design(잠정 설계 지정·단면적)을 병합 저장한다.
기존 data 요약을 보존하고 design 키만 갱신한다. 대상 측점을 chainage 근사로
찾으며(부동소수 오차 허용), 갱신 여부를 반환한다.
+ 구조물(비정규) 측점은 B05 확정이 파일만 쓰고 DB 행을 만들지 않으므로, 행이 없고
+ project_id가 오면 새 행을 삽입한다(upsert — 구조물 측점 설계 저장 보장).
"""
async with connection.cursor() as cursor:
await cursor.execute(
@@ -292,7 +352,21 @@ async def update_cross_section_design(
)
row = await cursor.fetchone()
if not row:
- return False
+ if project_id is None:
+ return False
+ await cursor.execute(
+ """
+ INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status)
+ VALUES (%s, %s, %s, %s, 'DRAFT')
+ """,
+ (
+ str(project_id),
+ route_id,
+ chainage_m,
+ json.dumps({"design": design}, ensure_ascii=False),
+ ),
+ )
+ return True
data = row[1]
if isinstance(data, str):
data = json.loads(data)
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py
index 1cd17df3..cc258675 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py
@@ -8,7 +8,7 @@ from typing import Any
from uuid import UUID
import aiomysql
-from fastapi import APIRouter, Body
+from fastapi import APIRouter, Body, Depends
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -32,13 +32,18 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_cross_sections_missing_design_chainages,
get_latest_section_options,
get_longitudinal_section,
+ get_project_standard_cross_section,
get_route_generation_source,
insert_cross_sections,
+ list_recent_company_projects,
merge_cross_section_design_patch,
merge_longitudinal_section_options,
update_cross_section_design,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
+ CompanyStandardListResponse,
+ CompanyStandardProject,
+ CompanyStandardResponse,
CrossDesignRequest,
CrossDesignResponse,
SectionConfirmRequest,
@@ -49,6 +54,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
+from common_util.common_util_auth import verify_session
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_workflow_state import complete_stage, get_workflow_state
@@ -107,6 +113,57 @@ async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, fl
return {"forest_road_min_width_m": FOREST_ROAD_MIN_WIDTH_M}
+# 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다.
+@router.get(
+ "/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse
+)
+async def list_company_standards(
+ project_id: UUID, session: dict[str, Any] = Depends(verify_session)
+) -> CompanyStandardListResponse:
+ """같은 회사의 최근 프로젝트 5개를 반환한다(설계값 보유 여부 무관)."""
+ company_id = session.get("company_id")
+ if company_id is None:
+ return CompanyStandardListResponse(projects=[])
+ pool = get_db_pool()
+ async with pool.acquire() as connection:
+ rows = await list_recent_company_projects(connection, company_id, project_id)
+ return CompanyStandardListResponse(
+ projects=[
+ CompanyStandardProject(project_id=str(row["project_id"]), name=row["name"])
+ for row in rows
+ ]
+ )
+
+
+@router.get(
+ "/{project_id}/sections/company-standards/{source_project_id}",
+ response_model=CompanyStandardResponse,
+)
+async def get_company_standard(
+ project_id: UUID,
+ source_project_id: UUID,
+ session: dict[str, Any] = Depends(verify_session),
+) -> CompanyStandardResponse | JSONResponse:
+ """특정 프로젝트의 표준횡단 설정값을 미리보기용으로 반환한다(회사 스코프 강제)."""
+ company_id = session.get("company_id")
+ if company_id is None:
+ return JSONResponse(
+ status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
+ )
+ pool = get_db_pool()
+ async with pool.acquire() as connection:
+ standard = await get_project_standard_cross_section(
+ connection, company_id, source_project_id
+ )
+ if standard is None:
+ return JSONResponse(
+ status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
+ )
+ return CompanyStandardResponse(
+ project_id=str(source_project_id), standard_cross_section=standard
+ )
+
+
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
"""경로의 종단면 요약을 조회한다."""
@@ -475,6 +532,8 @@ async def compute_cross_section_design(
ditch_type=request.ditch_type,
paved=request.paved,
standard=request.standard_cross_section,
+ rock_boundary_offset_m=request.rock_boundary_offset_m,
+ two_stage_slope=request.two_stage_slope,
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
@@ -488,6 +547,7 @@ async def compute_cross_section_design(
route_id=route_id,
chainage_m=request.chainage_m,
design=design,
+ project_id=project_id,
)
await connection.commit()
except Exception:
@@ -553,7 +613,11 @@ async def confirm_sections(
try:
for chainage_m, design in default_designs:
await update_cross_section_design(
- connection, route_id=route_id, chainage_m=chainage_m, design=design
+ connection,
+ route_id=route_id,
+ chainage_m=chainage_m,
+ design=design,
+ project_id=project_id,
)
if request and request.standard_cross_section:
await merge_longitudinal_section_options(
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py
index 876b0fbf..49506624 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py
@@ -11,6 +11,28 @@ class SectionRegenerateRequest(BaseModel):
cross_half_width_m: float = Field(..., gt=0)
+class CompanyStandardProject(BaseModel):
+ """같은 회사의 표준횡단 설정값 보유 프로젝트 항목(조회용)."""
+
+ project_id: str
+ name: str
+
+
+class CompanyStandardListResponse(BaseModel):
+ """같은 회사에서 설계값을 불러올 수 있는 프로젝트 목록."""
+
+ status: str = "success"
+ projects: list[CompanyStandardProject]
+
+
+class CompanyStandardResponse(BaseModel):
+ """특정 프로젝트의 표준횡단 설정값(미리보기·적용용, 회사 스코프)."""
+
+ status: str = "success"
+ project_id: str
+ standard_cross_section: dict[str, Any]
+
+
class CrossDesignRequest(BaseModel):
"""측점 하나의 표준횡단 설계(지반유형·단면유형) 지정 요청.
@@ -26,6 +48,10 @@ class CrossDesignRequest(BaseModel):
ditch_type: Literal["standard", "l_type"] = "standard"
# 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산한다.
paved: bool = False
+ # 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산에 쓴다.
+ rock_boundary_offset_m: float | None = None
+ # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능.
+ two_stage_slope: bool = True
# B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순.
standard_cross_section: dict[str, Any] | None = None
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts
index 17c6c352..fb5356a9 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts
@@ -56,6 +56,8 @@ export interface CrossDesignChange {
ditch_side: DitchSide | null;
ditch_type: DitchType;
paved: boolean;
+ /** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */
+ two_stage_slope: boolean;
}
/**
@@ -102,6 +104,32 @@ function segment(
return wrap;
}
+/** 온/오프 토글 하나(포장·2단계 경사 공용). 세그먼트와 같은 컨테이너/버튼 스타일을 쓴다. */
+function toggle(
+ legend: string,
+ on: boolean,
+ onLabel: string,
+ offLabel: string,
+ onToggle: () => void,
+): { wrap: HTMLElement; button: HTMLButtonElement } {
+ const wrap = document.createElement("div");
+ wrap.className = "b06-design__seg";
+ const legendEl = document.createElement("span");
+ legendEl.className = "b06-design__seg-legend";
+ legendEl.textContent = legend;
+ const buttons = document.createElement("div");
+ buttons.className = "b06-design__seg-buttons";
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `b06-design__btn${on ? " b06-design__btn--active" : ""}`;
+ button.textContent = on ? onLabel : offLabel;
+ button.setAttribute("aria-pressed", on ? "true" : "false");
+ button.addEventListener("click", onToggle);
+ buttons.append(button);
+ wrap.append(legendEl, buttons);
+ return { wrap, button };
+}
+
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */
function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): HTMLElement {
const wrap = document.createElement("div");
@@ -163,12 +191,14 @@ export function buildDesignControls(
ditch: DitchSide | null;
ditchType: DitchType;
paved: boolean;
+ twoStage: boolean;
} = {
ground: design?.ground_type ?? "soil",
mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"),
ditch: design?.ditch_side ?? null,
ditchType: design?.ditch_type ?? "standard",
paved: design?.paved ?? false,
+ twoStage: design?.two_stage_slope ?? true,
};
const bar = document.createElement("div");
bar.className = "b06-design";
@@ -186,6 +216,7 @@ export function buildDesignControls(
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
ditch_type: state.ditchType,
paved: state.paved,
+ two_stage_slope: state.twoStage,
});
};
@@ -215,36 +246,40 @@ export function buildDesignControls(
emit();
}),
);
+ // 2단계 경사 토글: 암 지반 + 절토가 있는 단면(양성 제외)에서만. 기본 활성, 해제 시 단일 암 경사.
+ const twoStage = toggle(
+ L("B06_Design_TwoStage_Legend"),
+ state.twoStage,
+ L("B06_Design_TwoStage_On"),
+ L("B06_Design_TwoStage_Off"),
+ () => {
+ state.twoStage = !state.twoStage;
+ emit();
+ },
+ );
+ bar.append(twoStage.wrap);
}
// 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경).
- const pavedWrap = document.createElement("div");
- pavedWrap.className = "b06-design__seg";
- const pavedLegend = document.createElement("span");
- pavedLegend.className = "b06-design__seg-legend";
- pavedLegend.textContent = L("B06_Design_Paved_Legend");
- const pavedButtons = document.createElement("div");
- pavedButtons.className = "b06-design__seg-buttons";
- const pavedButton = document.createElement("button");
- pavedButton.type = "button";
- pavedButton.className = `b06-design__btn${state.paved ? " b06-design__btn--active" : ""}`;
- pavedButton.textContent = state.paved ? L("B06_Design_Paved_On") : L("B06_Design_Paved_Off");
- pavedButton.setAttribute("aria-pressed", state.paved ? "true" : "false");
- pavedButton.addEventListener("click", () => {
- state.paved = !state.paved;
- emit();
- });
- pavedButtons.append(pavedButton);
- pavedWrap.append(pavedLegend, pavedButtons);
+ const paved = toggle(
+ L("B06_Design_Paved_Legend"),
+ state.paved,
+ L("B06_Design_Paved_On"),
+ L("B06_Design_Paved_Off"),
+ () => {
+ state.paved = !state.paved;
+ emit();
+ },
+ );
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
- pavedButton.title = L("B06_Design_Paved_Suggested");
+ paved.button.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
- pavedWrap.append(badge);
+ paved.wrap.append(badge);
}
- bar.append(pavedWrap);
+ bar.append(paved.wrap);
// 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합).
if (rockBoundary && isRock(state.ground)) {
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts
index d5fe2953..610a794c 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts
@@ -184,7 +184,9 @@ export function createCrossSectionCard(
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
const valid = sourceSamples.filter(validElevation);
const exaggeration = Math.max(verticalExaggeration, 0.1);
- const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * pixelsPerMeter;
+ // 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
+ // 시점이라 좌측(+offset)이 화면 왼쪽에 와야 B05 3D 방향 표시와 측구 방향이 일치한다(작업 C-3).
+ const x = (offset: number) => CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter;
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
const svg = svgElement("svg", {
class: "b06-section__chart",
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
index 63024693..c3dbacf8 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
@@ -232,6 +232,17 @@ export function createLongitudinalProfile(
class: "b06-chart__station-label",
}),
);
+ // 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다.
+ if (station.structure) {
+ marker.append(
+ svgText(station.structure, {
+ x: stationX,
+ y: LONG_PAD.top + 12,
+ "text-anchor": "middle",
+ class: "b06-chart__structure-label",
+ }),
+ );
+ }
svg.append(marker);
}
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts
index a3470c6b..d0bb6430 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts
@@ -123,17 +123,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise {
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
+ two_stage_slope: change.two_stage_slope,
};
sectionView.refreshCard(chainageM);
}
- // (2) 서버 계산 — 최신 요청만 반영.
+ // (2) 서버 계산 — 최신 요청만 반영. 암 경계 오프셋은 세션 우선값을 실어 2단계 무릎을 계산시킨다.
const seq = (designRequestSeq.get(chainageM) ?? 0) + 1;
designRequestSeq.set(chainageM, seq);
try {
const response = await computeCrossDesign(projectId, currentRouteId, {
chainage_m: chainageM,
...change,
+ rock_boundary_offset_m: rockBoundaryControl.offsetFor(target),
standard_cross_section: standardPanel?.getValues(),
});
if (designRequestSeq.get(chainageM) !== seq) return;
@@ -146,6 +148,29 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise {
}
}
+ /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
+ function changeFromDesign(chainageM: number): CrossDesignChange | null {
+ const target = sectionDetail?.cross_sections.find(
+ (section) => Math.abs(section.chainage_m - chainageM) < 0.01,
+ );
+ const design = target?.design;
+ if (!design) return null;
+ return {
+ ground_type: design.ground_type,
+ section_mode: design.section_mode,
+ ditch_side: design.ditch_side ?? null,
+ ditch_type: design.ditch_type ?? "standard",
+ paved: design.paved,
+ two_stage_slope: design.two_stage_slope ?? true,
+ };
+ }
+
+ /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
+ function recomputeIfRock(chainageM: number): void {
+ const change = changeFromDesign(chainageM);
+ if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
+ }
+
/* ── 암 경계선 오프셋(측점별) 세션 저장소 ─────────────────────────────
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
@@ -203,11 +228,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise {
rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100);
persistRockOffsets();
sectionView.refreshCard(chainageM);
+ recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
},
reset: (chainageM) => {
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
persistRockOffsets();
sectionView.refreshCard(chainageM);
+ recomputeIfRock(chainageM);
},
};
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts
new file mode 100644
index 00000000..e6f6f2b7
--- /dev/null
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts
@@ -0,0 +1,138 @@
+/* =============================================================================
+ * B06_wf3_ProfileCross_UI_Standard_Diagram.ts
+ * 표준 횡단면 설정 패널의 "변수 위치 안내" 모식도.
+ *
+ * 설정 패널의 각 편집값(노폭·노견·측구·절토/성토 경사·횡단경사)이 횡단면의 어느
+ * 위치를 의미하는지 라벨로 표시하는 스키매틱 SVG다. 실제 비율이 아니라 위치 안내용이며,
+ * config 값과 무관한 고정 도형이다(값은 패널 입력에서 편집).
+ * ========================================================================== */
+
+import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+function L(key: keyof typeof ui_locales): string {
+ return ui_locales[key][currentLanguageIndex];
+}
+
+function node(tag: string, attrs: Record, cls?: string): SVGElement {
+ const el = document.createElementNS(SVG_NS, tag);
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
+ if (cls) el.setAttribute("class", cls);
+ return el;
+}
+
+function text(x: number, y: number, value: string, cls: string, anchor = "middle"): SVGElement {
+ const t = node("text", { x, y, "text-anchor": anchor }, cls);
+ t.textContent = value;
+ return t;
+}
+
+/**
+ * 편절편성(좌 절토·우 성토) 표준 단면 모식도를 만든다. 좌표는 위치 안내 전용 고정값.
+ * 좌표 규약(+offset=좌)에 맞춰 절토측(측구 방향)을 왼쪽에 둔다.
+ */
+export function buildStandardDiagram(): HTMLElement {
+ const wrap = document.createElement("details");
+ wrap.className = "b06-std__diagram";
+ wrap.open = true;
+ const summary = document.createElement("summary");
+ summary.className = "b06-std__diagram-summary";
+ summary.textContent = L("B06_Std_Diagram_Title");
+ summary.title = L("B06_Std_Diagram_Toggle");
+ wrap.append(summary);
+
+ // 세로로 긴 형태(300×260) — 사면·측구·라벨 간 여백을 넉넉히 둔다.
+ const svg = node("svg", {
+ viewBox: "0 0 300 260",
+ role: "img",
+ "aria-label": L("B06_Std_Diagram_Title"),
+ }) as SVGSVGElement;
+ svg.setAttribute("class", "b06-std__diagram-svg");
+
+ // 지반선(원지반) — 좌측 높고 우측 낮은 사면. 절토/성토의 배경.
+ svg.append(
+ node(
+ "polyline",
+ { points: "10,60 95,122 205,138 290,210", fill: "none" },
+ "b06-diag__ground",
+ ),
+ );
+
+ // 노면(노견 포함): 좌 절토측이 측구 방향으로 살짝 낮게 기운다(횡단경사).
+ const roadLeft = 95;
+ const roadRight = 205;
+ const shoulderL = 108; // 노견 좌 경계
+ const shoulderR = 192; // 노견 우 경계
+ const roadYL = 128;
+ const roadYR = 122;
+ svg.append(
+ node(
+ "polyline",
+ { points: `${roadLeft},${roadYL} ${roadRight},${roadYR}`, fill: "none" },
+ "b06-diag__road",
+ ),
+ );
+ // 노견 경계 눈금(좌/우)
+ svg.append(
+ node(
+ "line",
+ { x1: shoulderL, y1: roadYL - 5, x2: shoulderL, y2: roadYL + 5 },
+ "b06-diag__tick",
+ ),
+ node(
+ "line",
+ { x1: shoulderR, y1: roadYR - 5, x2: shoulderR, y2: roadYR + 5 },
+ "b06-diag__tick",
+ ),
+ );
+
+ // 중심선(계획고): 노면 중앙 수직 파선.
+ const centerX = (roadLeft + roadRight) / 2;
+ svg.append(node("line", { x1: centerX, y1: 38, x2: centerX, y2: 162 }, "b06-diag__center"));
+
+ // 측구(절토측=좌): 노면 좌끝에서 아래로 파는 사다리꼴.
+ svg.append(
+ node(
+ "polygon",
+ {
+ points: `${roadLeft},${roadYL} ${roadLeft - 6},${roadYL + 16} ${roadLeft - 12},${roadYL + 16} ${roadLeft - 15},${roadYL}`,
+ },
+ "b06-diag__ditch",
+ ),
+ );
+
+ // 절토 사면(좌): 측구 바깥에서 원지반까지 상향.
+ svg.append(node("line", { x1: roadLeft - 15, y1: roadYL, x2: 26, y2: 66 }, "b06-diag__cut"));
+ // 성토 사면(우): 노면 우끝에서 원지반까지 하향.
+ svg.append(node("line", { x1: roadRight, y1: roadYR, x2: 278, y2: 210 }, "b06-diag__fill"));
+
+ // 횡단경사 라벨·화살표(노면 위, 측구 방향). 노견 좌/우 라벨은 같은 평행선상 좌·우에 둔다.
+ const labelLineY = 104;
+ svg.append(
+ node(
+ "line",
+ { x1: centerX + 14, y1: labelLineY + 8, x2: centerX - 14, y2: labelLineY + 12 },
+ "b06-diag__slope-arrow",
+ ),
+ text(centerX, labelLineY, L("B06_Std_Diagram_CrossSlope"), "b06-diag__label"),
+ text(shoulderL - 6, labelLineY, L("B06_Std_Diagram_ShoulderL"), "b06-diag__label-sm", "end"),
+ text(shoulderR + 6, labelLineY, L("B06_Std_Diagram_ShoulderR"), "b06-diag__label-sm", "start"),
+ );
+
+ // 나머지 라벨.
+ svg.append(
+ text(centerX, 30, L("B06_Std_Diagram_Center"), "b06-diag__label"),
+ text(centerX, 148, L("B06_Std_Diagram_Road"), "b06-diag__label"),
+ text(roadLeft - 32, roadYL + 32, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"),
+ text(46, 54, L("B06_Std_Diagram_Cut"), "b06-diag__label-sm", "middle"),
+ text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
+ );
+
+ wrap.append(svg);
+ const caption = document.createElement("p");
+ caption.className = "b06-std__diagram-caption";
+ caption.textContent = L("B06_Std_Diagram_Caption");
+ wrap.append(caption);
+ return wrap;
+}
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts
index b4c60083..6b01a57a 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts
@@ -12,11 +12,14 @@
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
-import { createButton, createInputField } from "@ui/ui_template_elements";
-import type {
- StandardCrossGroup,
- StandardCrossKey,
- StandardCrossSection,
+import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
+import { buildStandardDiagram } from "./B06_wf3_ProfileCross_UI_Standard_Diagram";
+import {
+ getCompanyStandard,
+ listCompanyStandards,
+ type StandardCrossGroup,
+ type StandardCrossKey,
+ type StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
@@ -164,6 +167,9 @@ export function createStandardPanel(
const root = document.createElement("div");
root.className = "b06-std";
+ // 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
+ root.append(buildStandardDiagram());
+
// 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다.
const body = document.createElement("div");
body.className = "b06-std__body";
@@ -214,6 +220,19 @@ export function createStandardPanel(
};
renderBody();
+ /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */
+ const applyStandard = (source: StandardCrossSection): void => {
+ (Object.keys(source) as StandardCrossKey[]).forEach((key) => {
+ if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup;
+ });
+ persist();
+ renderBody();
+ };
+
+ // 다른 프로젝트에서 설계값 불러오기: 선택·미리보기만으로는 현재 값이 바뀌지 않고,
+ // "적용" 버튼을 눌러야만 반영된다(작업 C-6).
+ const loader = buildProjectLoader(projectId, applyStandard);
+
const resetButton = createButton({
label: L("B06_Std_Reset"),
variant: "ghost",
@@ -230,7 +249,7 @@ export function createStandardPanel(
actions.className = "b06-std__actions";
actions.append(resetButton);
- root.append(body, actions);
+ root.append(body, loader, actions);
return {
root,
@@ -244,3 +263,75 @@ export function createStandardPanel(
},
};
}
+
+/**
+ * "다른 프로젝트에서 불러오기" 컨트롤. 같은 회사의 최근 프로젝트 5개를 보여주고,
+ * 사용자가 선택하면 그 프로젝트의 저장 설계값을 즉시 현재 설정(그룹 값)에 적용한다.
+ * 각 횡단면도 카드에서 사용자가 고른 버튼 옵션(지반유형·단면유형 등)은 건드리지 않는다
+ * — 이 값들은 측점별 design으로 별도 보관되며 패널 값과 독립이다.
+ */
+function buildProjectLoader(
+ projectId: string,
+ onApply: (source: StandardCrossSection) => void,
+): HTMLElement {
+ const wrap = document.createElement("details");
+ wrap.className = "b06-std__loader";
+ const summary = document.createElement("summary");
+ summary.className = "b06-std__loader-summary";
+ summary.textContent = L("B06_Std_Load_Title");
+ wrap.append(summary);
+
+ const status = document.createElement("p");
+ status.className = "b06-std__loader-status";
+ status.textContent = L("B06_Std_Load_Loading");
+
+ const field = createSelectField({
+ label: L("B06_Std_Load_Select"),
+ options: [{ value: "", text: L("B06_Std_Load_Placeholder") }],
+ onChange: (value) => void onSelect(value),
+ });
+ field.root.hidden = true;
+
+ async function onSelect(sourceId: string): Promise {
+ if (!sourceId) {
+ status.textContent = "";
+ return;
+ }
+ status.textContent = L("B06_Std_Load_Loading");
+ try {
+ const response = await getCompanyStandard(projectId, sourceId);
+ onApply(response.standard_cross_section);
+ status.textContent = L("B06_Std_Load_Applied");
+ } catch (error) {
+ // 404 = 해당 프로젝트에 저장된 설계값 없음(목록은 보유 여부 무관 최근 5개).
+ const message = error instanceof Error ? error.message : "";
+ status.textContent = message.includes("찾을 수 없")
+ ? L("B06_Std_Load_None")
+ : L("B06_Std_Load_Failed");
+ }
+ }
+
+ // 같은 회사 최근 프로젝트 5개 비동기 로드 → 셀렉트 채우기.
+ void (async () => {
+ try {
+ const response = await listCompanyStandards(projectId);
+ if (!response.projects.length) {
+ status.textContent = L("B06_Std_Load_Empty");
+ return;
+ }
+ for (const project of response.projects) {
+ const option = document.createElement("option");
+ option.value = project.project_id;
+ option.textContent = project.name;
+ field.select.append(option);
+ }
+ field.root.hidden = false;
+ status.textContent = "";
+ } catch {
+ status.textContent = L("B06_Std_Load_Failed");
+ }
+ })();
+
+ wrap.append(status, field.root);
+ return wrap;
+}
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css
index 463b3b2d..d42c1dac 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css
@@ -92,6 +92,126 @@
gap: var(--spacing-16);
}
+/* ─── 변수 위치 안내 모식도 (작업 C-5) ───────────────────────────── */
+.b06-std__diagram {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-inputs);
+ background-color: var(--color-surface);
+ padding: var(--spacing-8);
+}
+
+.b06-std__diagram-summary {
+ cursor: pointer;
+ font-size: var(--text-caption);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ user-select: none;
+}
+
+.b06-std__diagram-svg {
+ width: 100%;
+ height: auto;
+ margin-top: var(--spacing-8);
+}
+
+.b06-std__diagram-caption {
+ margin: var(--spacing-8) 0 0;
+ font-size: var(--text-caption);
+ color: var(--color-text-muted);
+}
+
+/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
+.b06-std__loader {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-8);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-inputs);
+ background-color: var(--color-surface);
+ padding: var(--spacing-8);
+}
+
+.b06-std__loader-summary {
+ cursor: pointer;
+ font-size: var(--text-caption);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ user-select: none;
+}
+
+.b06-std__loader-status {
+ margin: 0;
+ font-size: var(--text-caption);
+ color: var(--color-text-muted);
+}
+
+.b06-std__loader-preview {
+ margin: 0;
+ padding: var(--spacing-8);
+ border-radius: var(--radius-inputs);
+ background-color: var(--color-surface-raised);
+ color: var(--color-text-body);
+ font-size: var(--text-caption);
+ white-space: pre-wrap;
+ overflow-x: auto;
+}
+
+.b06-diag__ground {
+ fill: none;
+ stroke: var(--color-text-muted);
+ stroke-width: 1;
+ stroke-dasharray: 3 2;
+}
+
+.b06-diag__road {
+ fill: none;
+ stroke: var(--color-text-body);
+ stroke-width: 2;
+}
+
+.b06-diag__center {
+ stroke: var(--color-royal-amethyst, rgb(109 40 217));
+ stroke-width: 1;
+ stroke-dasharray: 4 3;
+}
+
+.b06-diag__tick {
+ stroke: var(--color-text-secondary);
+ stroke-width: 1;
+}
+
+.b06-diag__ditch {
+ fill: color-mix(in srgb, rgb(37 99 235) 20%, transparent);
+ stroke: rgb(37 99 235);
+ stroke-width: 1;
+}
+
+.b06-diag__cut {
+ stroke: rgb(220 38 38);
+ stroke-width: 1.5;
+}
+
+.b06-diag__fill {
+ stroke: rgb(37 99 235);
+ stroke-width: 1.5;
+}
+
+.b06-diag__slope-arrow {
+ stroke: var(--color-text-secondary);
+ stroke-width: 1;
+ marker-end: none;
+}
+
+.b06-diag__label {
+ fill: var(--color-text-body);
+ font-size: 9px;
+}
+
+.b06-diag__label-sm {
+ fill: var(--color-text-secondary);
+ font-size: 8px;
+}
+
.b06-std__group {
display: flex;
flex-direction: column;
@@ -340,6 +460,13 @@
font-size: 10px;
}
+/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
+.b06-chart__structure-label {
+ font-size: 9px;
+ font-weight: var(--font-weight-medium);
+ fill: rgb(180 83 9);
+}
+
.b06-chart__axis-label {
fill: var(--color-text-body);
font-size: var(--text-caption);
diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts
index bb4c1824..3e0dfc1a 100644
--- a/ui_template/ui_template_locale.ts
+++ b/ui_template/ui_template_locale.ts
@@ -819,6 +819,9 @@ export const ui_locales = {
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
+ B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"],
+ B06_Design_TwoStage_On: ["적용", "On"],
+ B06_Design_TwoStage_Off: ["단일 경사", "Single slope"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",
@@ -855,6 +858,32 @@ export const ui_locales = {
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
+ B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"],
+ B06_Std_Load_Select: ["프로젝트 선택", "Select project"],
+ B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"],
+ B06_Std_Load_Empty: ["같은 회사에 불러올 설계값이 없습니다.", "No saved designs in your company."],
+ B06_Std_Load_Loading: ["불러오는 중…", "Loading…"],
+ B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"],
+ B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],
+ B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."],
+ B06_Std_Load_None: [
+ "선택한 프로젝트에 저장된 설계값이 없습니다.",
+ "The selected project has no saved design values.",
+ ],
+ B06_Std_Diagram_Title: ["변수 위치 안내", "Variable position guide"],
+ B06_Std_Diagram_Toggle: ["단면 그림 보기/숨기기", "Show/hide section guide"],
+ B06_Std_Diagram_Road: ["노폭", "Road"],
+ B06_Std_Diagram_ShoulderL: ["노견 좌", "Shoulder L"],
+ B06_Std_Diagram_ShoulderR: ["노견 우", "Shoulder R"],
+ B06_Std_Diagram_Ditch: ["측구", "Ditch"],
+ B06_Std_Diagram_Cut: ["절토경사", "Cut slope"],
+ B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
+ B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
+ B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
+ B06_Std_Diagram_Caption: [
+ "표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
+ "Standard cut-fill section schematic — shows where each value applies (not to scale).",
+ ],
/* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],