feat(B05): 횡단배수 최소고를 사용자 스위치로 빼고 3D 갱신을 버튼으로 모은다

횡단배수 최소 계획고가 두 곳에서 강제됐다. 자동설계는 배관 자리 계획고를 시설
여유만큼 들어 올렸고(B05_Profile_Engine_Sections), 화면은 그 아래로 내리는 편집을
차단했다(blocksMinCover). 배수 자리마다 계획고가 끌려 올라가 제어가 어려웠다.

산식(common_util_drainage_pipes.facility_clearance_m)은 그대로 두고 적용 여부만
enforce_pipe_clearance 스위치 하나로 뺐다. 기본은 해제라 자동 전처리가 계획고를
들어 올리지 않고, 편집도 막히지 않는다. 부족분 경고(minCoverWarningText)는 켜고
끔과 무관하게 계속 뜬다. 스위치는 요청 → DB 저장값 → 해제 순으로 정해지며,
계획선 criteria와 B06 재생성 경로도 확정 당시 값을 따라간다.

3D 예상형상은 계획선 편집마다 500ms 디바운스로 따라오던 것을 없앴다. 편집이
쌓이면 상단 [3D 업데이트] 버튼에 대기 점이 붙고, 누를 때 코리도와 측점선을 한 번에
갱신한다. 최초 진입의 자동 그림은 그대로다.
This commit is contained in:
2026-09-01 18:10:04 +09:00
parent 0a1ebcfbb5
commit e823293e40
12 changed files with 140 additions and 29 deletions
+3
View File
@@ -59,6 +59,8 @@ export interface RouteSolveRequest {
balance_segment_length_m?: number | null;
start_elevation_offset_m?: number | null;
end_elevation_offset_m?: number | null;
/** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */
enforce_pipe_clearance?: boolean | null;
}
/** 계획선 산출 요약 (실패 시 null) */
@@ -140,6 +142,7 @@ export interface RouteLatestResponse {
balance_segment_length_m?: number | null;
start_elevation_offset_m?: number | null;
end_elevation_offset_m?: number | null;
enforce_pipe_clearance?: boolean | null;
} | null;
}
+12
View File
@@ -61,6 +61,10 @@ class GradeDesignOptions:
balance_segment_length_m: float | None = None
start_elevation_offset_m: float = 0.0
end_elevation_offset_m: float = 0.0
# 횡단배수 최소고를 계획선에 강제할지(2026-09-01 사용자 지시). 켜면 배관 자리 계획고를
# 시설 여유(`facility_clearance_m`)만큼 들어 올리고 그 아래로 내리는 편집도 막는다.
# 기본은 해제 — 자동설계가 배수 자리마다 계획고를 잡아당겨 제어가 어려웠다.
enforce_pipe_clearance: bool = False
warnings: tuple[str, ...] = field(default_factory=tuple)
def validate(self) -> None:
@@ -95,6 +99,7 @@ class GradeDesignOptions:
"balance_segment_length_m": self.balance_segment_length_m,
"start_elevation_offset_m": self.start_elevation_offset_m,
"end_elevation_offset_m": self.end_elevation_offset_m,
"enforce_pipe_clearance": self.enforce_pipe_clearance,
}
@@ -211,6 +216,13 @@ def resolve_grade_options(
requested.get("end_elevation_offset_m"), stored.get("end_elevation_offset_m"), 0.0
)
),
enforce_pipe_clearance=bool(
_pick(
requested.get("enforce_pipe_clearance"),
stored.get("enforce_pipe_clearance"),
False,
)
),
warnings=tuple(warnings),
)
@@ -380,6 +380,7 @@ def rebuild_alignment_profile(
terrain_type=str(criteria.get("terrain_type") or "normal"),
paved=policy.paved,
main_direction=str(criteria.get("main_direction") or "auto"),
enforce_pipe_clearance=bool(criteria.get("enforce_pipe_clearance") or False),
)
direction = str(criteria.get("resolved_main_direction") or "none")
return alignment, _profile_entry(
+8 -2
View File
@@ -368,8 +368,14 @@ def run_section_generation(
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점(측점 생성과 같은 목록).
pipe_chainages=[pipe.chainage_m for pipe in pipes],
# 시설 제원이 요구하는 최소 여유(관경+토피 등) — 계획선이 그만큼 들려야 관·구체가
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
pipe_clearances=dict(pipe_anchor_clearances(pipes)),
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시). 다만 이 들어올림이 배수 자리마다
# 계획고를 잡아당겨 제어가 어려워, 적용 여부를 사용자 스위치로 뺐다(2026-09-01
# 사용자 지시 — 기본 해제). 산식은 그대로이고 켜고 끄는 것만 바뀐다.
pipe_clearances=(
dict(pipe_anchor_clearances(pipes))
if grade_options and grade_options.enforce_pipe_clearance
else None
),
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
+6
View File
@@ -105,6 +105,11 @@ class RouteSolveRequest(BaseModel):
balance_segment_length_m: float | None = Field(default=None, gt=0)
start_elevation_offset_m: float | None = None
end_elevation_offset_m: float | None = None
# 횡단배수 최소고 강제 여부(2026-09-01 사용자 지시). 기본 해제 — 켜면 계획선이 배관
# 자리에서 시설 여유만큼 들리고, 그 아래로 내리는 편집도 화면에서 막힌다.
enforce_pipe_clearance: bool | None = Field(
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
)
@model_validator(mode="after")
def validate_choices(self) -> "RouteSolveRequest":
@@ -132,6 +137,7 @@ class RouteSolveRequest(BaseModel):
"balance_segment_length_m": self.balance_segment_length_m,
"start_elevation_offset_m": self.start_elevation_offset_m,
"end_elevation_offset_m": self.end_elevation_offset_m,
"enforce_pipe_clearance": self.enforce_pipe_clearance,
}
def points_data(self) -> dict[str, Any]:
+5 -4
View File
@@ -445,7 +445,8 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P
if (ok) entry.dirty = false;
}
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). */
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
export function refreshCorridor(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string,
@@ -453,9 +454,9 @@ export function refreshCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): void {
if (!routeId) return;
void ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
.then((build) => viewer.setCorridor(build))
.catch(() => viewer.setCorridor(null));
}
+30 -23
View File
@@ -95,26 +95,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
() => bridge.clearProjectedStations(),
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신하면 3D 코리도도 재빌드(2026-08-23).
// 연타·드래그 중 프리뷰 응답마다 전체 파이프라인(빌드+스냅+클립)을 돌리면
// 프리즈가 오므로, 편집이 잦아든 뒤 1회만 돌린다(2026-08-23 사용자 보고).
onCrossDesignsUpdated: () => {
window.clearTimeout(corridorRefreshTimer);
corridorRefreshTimer = window.setTimeout(() => {
if (currentSectionDetail && latest?.route?.id && (latest.route_points?.length ?? 0) > 1) {
refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
latest.route_points,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
}
}, 500);
},
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 디바운스 자동 갱신조차 편집 중 조작을 무겁게 만들어,
// 갱신은 상단 [3D 업데이트] 버튼으로 모았다. 여기서는 밀린 편집이 있다는 표시만 한다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
onPipesChanged: (pipes) => {
@@ -184,8 +168,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
/** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */
let corridorRefreshTimer = 0;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
@@ -282,6 +264,22 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
if (!currentSectionDetail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
},
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
@@ -333,6 +331,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
function markStale(): void {
if (restoring || !routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
@@ -386,6 +386,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
enforcePipeClearance:
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined,
});
viewer.markers.setPoints(restorePoints(next));
}
@@ -454,6 +456,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면
// 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다.
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 여부도 그릴 때마다 현재 설정으로 맞춘다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(bridge.irregularStations());
renderStationLines(detail);
@@ -462,7 +466,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
refreshCorridor(
void refreshCorridor(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
@@ -470,6 +474,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
panel.setCorridorPending(false);
}
}
@@ -599,6 +605,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
min_tangent_length_m: values.minTangentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
enforce_pipe_clearance: values.enforcePipeClearance,
});
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
uphillOverrides.clear();
+43
View File
@@ -32,6 +32,9 @@ export interface RoutePanelValues {
minTangentLength: number | null;
startElevationOffset: number | null;
endElevationOffset: number | null;
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
enforcePipeClearance: boolean;
}
/**
@@ -102,6 +105,9 @@ interface PanelCallbacks {
onSurfaceGrayscale: (grayscale: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
/** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자
* 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */
onCorridorRefresh: () => void | Promise<void>;
onMovePoint: () => void;
onDeletePoint: () => void;
onRadiusChange: (radius: number) => void;
@@ -226,15 +232,36 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
);
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
// 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장).
const corridorRefresh = button(
"3D 업데이트",
() => {
corridorRefresh.disabled = true;
corridorRefresh.textContent = "갱신 중…";
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
corridorRefresh.disabled = false;
corridorRefresh.textContent = "3D 업데이트";
corridorRefresh.classList.remove("is-pending");
});
},
"glass",
);
corridorRefresh.title =
"계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다.";
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";
const separator2 = separator1.cloneNode() as HTMLSpanElement;
const separator3 = separator1.cloneNode() as HTMLSpanElement;
viewControls.append(
viewButtons,
separator1,
visibilityButtons,
separator2,
button("뷰 초기화", callbacks.onResetView, "glass"),
separator3,
corridorRefresh,
);
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
@@ -373,6 +400,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const terrainType = terrainField.select;
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
// 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는
// 종단 상단 표시줄에 계속 뜬다.
const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false);
enforcePipeClearance.title =
"켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" +
"끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본).";
const maxGradePct = numberField("최대 종단기울기 (%)");
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
const minTangentLength = numberField("최소 직선 길이 (m)");
@@ -405,6 +439,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
speedField.root,
terrainField.root,
criteriaNote,
enforcePipeClearance.wrapper,
gradeAdvanced,
);
@@ -531,6 +566,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minTangentLength,
startElevationOffset,
endElevationOffset,
enforcePipeClearance,
];
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
@@ -557,6 +593,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
stationDisplayOffset,
/** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */
gradeLimitPct,
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
setCorridorPending(pending: boolean) {
corridorRefresh.classList.toggle("is-pending", pending);
},
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
@@ -579,6 +619,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minTangentLength: parseOptional(minTangentLength),
startElevationOffset: parseOptional(startElevationOffset),
endElevationOffset: parseOptional(endElevationOffset),
enforcePipeClearance: enforcePipeClearance.checked,
};
},
restore(values: Partial<RoutePanelValues>) {
@@ -611,6 +652,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.value = String(values.startElevationOffset);
if (values.endElevationOffset != null)
endElevationOffset.value = String(values.endElevationOffset);
if (values.enforcePipeClearance != null)
enforcePipeClearance.checked = values.enforcePipeClearance;
syncCriteria();
},
setSelected(point: PlacedRoutePoint | null) {
@@ -189,6 +189,8 @@ export function createRouteProfilePanel(
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */
let enforceMinCover = false;
const drainagePanel = createDrainagePanel({
onPipesChanged: (pipes) => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
@@ -502,6 +504,7 @@ export function createRouteProfilePanel(
stationInterval: () => stationInterval,
irregularStations: () => irregularStations,
minCoverTargets: () => minCoverTargets,
enforceMinCover: () => enforceMinCover,
structures: () => structures,
structureTypes: () => structureTypes,
selectedStationId: () => selectedStationId,
@@ -624,6 +627,10 @@ export function createRouteProfilePanel(
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
},
setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
@@ -68,6 +68,8 @@ export interface ProfileRenderContext {
irregularStations: () => IrregularStation[];
/** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */
minCoverTargets: () => MinCoverPoint[];
/** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */
enforceMinCover: () => boolean;
structures: () => StructureInstance[];
structureTypes: () => StructureType[];
selectedStationId: () => string | null;
@@ -292,6 +294,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
};
const blocksMinCover = (next: AlignmentEdits): boolean => {
if (!base) return false;
// 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제).
// 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다.
if (!ctx.enforceMinCover()) return false;
const targets = ctx.minCoverTargets();
if (!targets.length) return false;
// 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만
+17
View File
@@ -208,6 +208,23 @@
background: color-mix(in srgb, var(--color-border) 75%, transparent);
}
/* [3D 업데이트] 대기 표시 — 계획선 편집이 3D에 아직 안 들어갔음을 점으로 알린다. */
.b05-route__view-controls .ui-btn.is-pending {
position: relative;
border-color: var(--color-warning, #d98a00);
}
.b05-route__view-controls .ui-btn.is-pending::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-warning, #d98a00);
}
.b05-route__panel {
display: flex;
flex-direction: column;
+3
View File
@@ -385,6 +385,9 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
"balance_segment_length_m",
"start_elevation_offset_m",
"end_elevation_offset_m",
# 횡단배수 최소고 강제 스위치도 확정 당시 값을 따라야 한다 — 빠뜨리면 재생성이
# 기본(해제)으로 돌아가 켜 둔 계획선이 내려앉는다(2026-09-01).
"enforce_pipe_clearance",
)
if stage_params.get(key) is not None
}