feat(B06): 집수정 좌우·상하 이동 및 내공 조정 + 설계선 스냅 급경사 제거

- 집수정 조정값(BasinAdjust: 내공 폭·높이, 좌우 lateralM, 상하 slopeM) 신설.
  측점별 세션 보관 + 백엔드 design.basin_adjust 저장 경로(Schema·Router) 연동
- 조정창에 집수정 조정 항목 추가, I형 관 시작점을 벽 안쪽 변과 원지반의
  교차점으로 산출(이분법), 집수정 성토(되메움)선 5° → 성토 비탈 1:1.2로 통일
- 설계선 트림 수정: 배수관 레이아웃이 사면 폴리라인을 준 쪽은 그 시작점(노견)에서
  설계선을 끊는다 — 종전에는 트림 경계에서 끝점 표고를 벽 상단으로 강제 스냅해
  집수정을 좌우로 옮기면 마지막 구간이 수직으로 튀는 선이 남았다(사용자 지적)
- basinApproachPoints의 길이 0 중복 꼭짓점 제거

검증: 4+4.3(집수정 I형 좌우 2.9m 이동)·13+15.7·5+0.0·2+0.0에서 급경사
세그먼트 0건 · tsc --noEmit 통과 · ruff check 통과 · prettier 적용 ·
pytest tmp/tests/ 148 passed, 7 skipped · B06_Section 700줄 초과 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:50:45 +09:00
co-authored by Claude Opus 5
parent e54da341ea
commit 2a247184fd
17 changed files with 362 additions and 53 deletions
+14
View File
@@ -244,6 +244,13 @@ export type DitchType = "standard" | "l_type";
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
export interface CrossDesign {
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
basin_adjust?: {
innerWidthM: number;
innerHeightM: number;
lateralM: number;
slopeM: number;
};
ground_type: GroundType;
geometry_preset: "soil" | "rock";
section_mode: SectionMode;
@@ -333,6 +340,13 @@ export interface CrossSectionPatch {
rock_boundary_offset_m?: number;
/** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */
display_half_width_m?: number;
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
basin_adjust?: {
innerWidthM: number;
innerHeightM: number;
lateralM: number;
slopeM: number;
};
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
+4 -5
View File
@@ -640,11 +640,10 @@ async def compute_cross_section_design(
for record in stored_designs:
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
stored_design = record.get("design")
if (
isinstance(stored_design, dict)
and stored_design.get("display_half_width_m") is not None
):
design["display_half_width_m"] = stored_design["display_half_width_m"]
if isinstance(stored_design, dict):
for key in ("display_half_width_m", "inlet_structure", "basin_adjust"):
if stored_design.get(key) is not None:
design[key] = stored_design[key]
break
await connection.begin()
try:
@@ -79,6 +79,10 @@ async def _apply_section_edits(
patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m
if patch_item.display_half_width_m is not None:
patch["display_half_width_m"] = patch_item.display_half_width_m
if patch_item.inlet_structure is not None:
patch["inlet_structure"] = patch_item.inlet_structure
if patch_item.basin_adjust is not None:
patch["basin_adjust"] = patch_item.basin_adjust.model_dump()
if patch:
await merge_cross_section_design_patch(
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
+4
View File
@@ -136,4 +136,8 @@ def recompute_designs_for_alignment(
design.update(status="provisional", pavement_suggested=suggested)
if stored.get("display_half_width_m") is not None:
design["display_half_width_m"] = stored["display_half_width_m"]
if stored.get("inlet_structure") is not None:
design["inlet_structure"] = stored["inlet_structure"]
if stored.get("basin_adjust") is not None:
design["basin_adjust"] = stored["basin_adjust"]
section["design"] = design
+11
View File
@@ -66,6 +66,15 @@ class CrossDesignResponse(BaseModel):
design: dict[str, Any]
class BasinAdjustPatch(BaseModel):
"""집수정 내부 치수와 기본 위치 대비 이동량."""
innerWidthM: float = Field(..., ge=1.0, le=2.0)
innerHeightM: float = Field(..., ge=1.0, le=2.0)
lateralM: float = Field(..., ge=0.0, le=10.0)
slopeM: float = Field(..., ge=0.0, le=10.0)
class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
@@ -74,6 +83,8 @@ class CrossSectionPatch(BaseModel):
rock_boundary_offset_m: float | None = None
# 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06).
display_half_width_m: float | None = Field(default=None, gt=0)
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
basin_adjust: BasinAdjustPatch | None = None
class SectionConfirmRequest(BaseModel):
+2 -3
View File
@@ -254,8 +254,7 @@ export function appendCulvertOverlay(
layer.append(cut);
}
if (basin.fillLine) {
// 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 경사 5도(임시값,
// 2026-08-22 사용자 지정).
// 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 성토 비탈 1:1.2.
const fill = document.createElementNS(SVG_NS, "line");
fill.setAttribute("x1", String(x(basin.fillLine.from.offset)));
fill.setAttribute("y1", String(toDisplayY(basin.fillLine.from.elevation)));
@@ -266,7 +265,7 @@ export function appendCulvertOverlay(
fillTitle.textContent =
basin.shape === "I"
? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결"
: `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 5° 경사로 채움`;
: `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 1:1.2로 채움`;
fill.append(fillTitle);
layer.append(fill);
}
@@ -22,11 +22,50 @@ import {
import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve";
import type {
BasinLayout,
BasinAdjust,
BasinShape,
EndFace,
OffsetPoint,
InletStructureChoice,
} from "./B06_Section_UI_Cross_Culvert_Types";
export function resolveBasinChoice(
choice: InletStructureChoice,
ruleReason: BasinLayout["reason"] | null,
): { reason: BasinLayout["reason"] | null; shape: BasinShape } {
const selected = choice === "I" || choice === "L" || choice === "U";
return {
reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason,
shape: selected ? choice : "L",
};
}
export function basinApproachPoints(
edge: { offset_m: number; elevation_m: number },
outward: number,
adjust: BasinAdjust,
trim: OffsetPoint,
): OffsetPoint[] | null {
// 접근선은 **설계선이 벽 상단까지 따라올 경로**다. 이 선을 주지 않으면 설계선이
// 자기 성토 물매(1:1.2)로 내려갔다가 트림 표고(벽 상단)로 되튀어 올라 급경사
// 선이 남는다(2026-08-22 사용자 지적). 좌우만 옮겨도 반드시 준다 — 집수정이
// 나간 만큼 노견이 수평으로 연장되는 것이 맞는 표현이다.
if (adjust.lateralM <= 1e-6 && adjust.slopeM <= 1e-6) return null;
const points: OffsetPoint[] = [{ offset: edge.offset_m, elevation: edge.elevation_m }];
if (adjust.lateralM > 1e-6) {
points.push({
offset: edge.offset_m + outward * adjust.lateralM,
elevation: edge.elevation_m,
});
}
// 중복 꼭짓점 제거 — 수평 선반 끝과 벽 상단이 겹치면 길이 0 구간이 남는다.
const last = points[points.length - 1];
if (Math.hypot(trim.offset - last.offset, trim.elevation - last.elevation) > 1e-6) {
points.push(trim);
}
return points.length >= 2 ? points : null;
}
/** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */
export interface BasinBuildInput {
/** 관 유입 기준점(노견 끝 offset, invert 표고 — ㄴ·ㄷ형은 상승분 반영 후). */
@@ -42,6 +81,7 @@ export interface BasinBuildInput {
groundAt: (offset: number) => number;
/** 표준단면 절토 경사(1:n) — 절토 계획선용. */
cutSlopeRatio: number;
adjust: BasinAdjust;
}
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
@@ -58,21 +98,35 @@ export interface BasinBuildResult {
/** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */
export const BASIN_INNER_WIDTH_M = 1.0;
const INNER_WIDTH_M = BASIN_INNER_WIDTH_M;
/** 집수정 주변 성토(되메움) 표면 경사(도) — 2026-08-22 사용자 지정 "일단 5도". */
const BASIN_FILL_ANGLE_DEG = 5;
/** 집수정 주변 성토(되메움) 기울기(1:n) — 성토 비탈 기본 1:1.2. */
const BASIN_FILL_SLOPE_RATIO = 1.2;
/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */
export function buildBasin(input: BasinBuildInput): BasinBuildResult {
const { anchor, outward, shape, reason, diameterM, edge, groundAt, cutSlopeRatio } = input;
const {
anchor: baseAnchor,
outward,
shape,
reason,
diameterM,
edge,
groundAt,
cutSlopeRatio,
adjust,
} = input;
const movedAnchor = {
offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM),
elevation: baseAnchor.elevation - adjust.slopeM / 1.2,
};
const anchor = movedAnchor;
const memberT = BASIN_MEMBER_THICKNESS_M;
const floorThickness = memberT;
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
const wallHeight =
shape === "I"
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
: BASIN_INNER_HEIGHT_M;
: adjust.innerHeightM;
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20).
// bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용).
const defaultBottom = anchor.elevation - floorThickness;
@@ -112,6 +166,26 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
{ kind: "wall", points: wallOf(wallBase, outward, iWallBottom) },
];
const iWallPoints = parts[0].points;
const iGroundIntersection = (): OffsetPoint => {
const low = iWallPoints[3];
const high = iWallPoints[2];
let lo = 0;
let hi = 1;
for (let pass = 0; pass < 32; pass += 1) {
const mid = (lo + hi) / 2;
const point = {
offset: low.offset + (high.offset - low.offset) * mid,
elevation: low.elevation + (high.elevation - low.elevation) * mid,
};
if (point.elevation < groundAt(point.offset)) lo = mid;
else hi = mid;
}
const t = (lo + hi) / 2;
return {
offset: low.offset + (high.offset - low.offset) * t,
elevation: low.elevation + (high.elevation - low.elevation) * t,
};
};
// ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다
// (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다.
const wallOuterAt = (elevation: number): number => {
@@ -121,7 +195,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
};
const floorTopInner = wallOuterAt(anchor.elevation);
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
const floorOuter = floorTopInner + outward * (INNER_WIDTH_M + memberT);
const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT);
const floorTaper = REVET_LEAN_RATIO * floorThickness;
if (shape !== "I") {
parts.push({
@@ -147,7 +221,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
// 구조물 계류측 끝(ㄴ형 = 바닥 바깥 끝 상단, ㄷ형 = 막음벽 계류측 상단 꼭짓점)과
// 원지반의 관계(2026-08-22 사용자 ①):
// · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선**
// · 원지반 밖으로 나오면 → **성토(되메움)선** — 경사 5도(임시값)로 지반까지.
// · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지.
let basinCut: BasinLayout["cutLine"] = null;
let basinFill: BasinLayout["cutLine"] = null;
if (shape !== "I") {
@@ -167,8 +241,11 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
break;
}
}
} else if (groundHere < refTop.elevation - 0.05) {
const dropPerM = Math.tan((BASIN_FILL_ANGLE_DEG * Math.PI) / 180);
} else if (
groundHere < refTop.elevation - 0.05 &&
!(adjust.lateralM > 1e-6 && adjust.slopeM <= 1e-6)
) {
const dropPerM = 1 / BASIN_FILL_SLOPE_RATIO;
for (let run = 0.05; run <= 30; run += 0.05) {
const probe = refTop.offset + outward * run;
const elevation = refTop.elevation - dropPerM * run;
@@ -203,7 +280,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
},
pipeEnd:
shape === "I"
? { offset: anchor.offset + outward * INNER_WIDTH_M, elevation: anchor.elevation }
? iGroundIntersection()
: { offset: floorTopInner, elevation: anchor.elevation },
trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight,
trimElevation: anchor.elevation + wallHeight,
@@ -248,6 +325,12 @@ export function inletChoiceAvailability(input: {
edge,
groundAt,
cutSlopeRatio: input.cutSlopeRatio,
adjust: {
innerWidthM: BASIN_INNER_WIDTH_M,
innerHeightM: BASIN_INNER_HEIGHT_M,
lateralM: 0,
slopeM: 0,
},
});
return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset };
}
@@ -88,8 +88,8 @@ export function revetHeightLimit(form: string | null | undefined): number {
/* · (2026-08-22 )
* ** ** :
* 2.0 / 3.0(.md §1) / 5.0(
* "일단 5m", .md 4.0 · ).
* 2.0 / 3.0(.md §1) / 5.0( ,
* ).
* 0.5m ( invert )~ ** ** . */
export type RevetMaterial = "dry" | "wet" | "concrete";
@@ -118,7 +118,7 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
export const PIPE_WALL_MIN_HEIGHT_M = 2.0;
/** 배관 기슭막이 기본 높이(m) — 재질과 무관하게 2.0(2026-08-22 사용자 정정). */
export const PIPE_WALL_DEFAULT_HEIGHT_M = 2.0;
/** 일반(추가) 기슭막이 높이 조작 하한(m). */
/** 일반(추가) 기슭막이 높이 조작 하한(m) — 프로젝트 기본값 0.5m. */
export const EXTRA_WALL_MIN_HEIGHT_M = 0.5;
/** 일반(추가) 기슭막이 기본 높이(m). */
export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5;
@@ -161,7 +161,7 @@ export const BASIN_BOTTOM_CLEARANCE_M = 0.5;
/**
* (m) (L)· ** ~ ** .
* 1.2(2026-08-22 "일단 1.2로") .
* 1.2m(2026-08-22 ). .
* I형() .
*/
export const BASIN_INNER_HEIGHT_M = 1.2;
@@ -10,7 +10,6 @@
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
BASIN_INNER_HEIGHT_M,
BASIN_MAX_FILL_SLOPE_M,
FILL_MIN_RISE_M,
materialFromForm,
@@ -32,7 +31,7 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinShape,
BasinAdjust,
CulvertLayout,
EndFace,
InletStructureChoice,
@@ -41,6 +40,7 @@ import type {
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { DEFAULT_BASIN_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
// 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다.
export * from "./B06_Section_UI_Cross_Culvert_Const";
@@ -48,9 +48,11 @@ export * from "./B06_Section_UI_Cross_Culvert_Types";
import {
applyBasinPipeFill,
basinApproachPoints,
BASIN_INNER_WIDTH_M,
buildBasin,
inletChoiceAvailability,
resolveBasinChoice,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildOutletExtras, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
@@ -76,9 +78,8 @@ export function computeCulvertLayout(
groundSamples: SectionSample[],
/** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */
revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] },
/** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */
inletStructure?: InletStructureChoice,
/** 다단 등간격 배치 1회성 트리거(2026-08-22 사용자 ①). */
basinAdjustment?: BasinAdjust,
equalizeExtras?: boolean,
): CulvertLayout | null {
const culvert = section.culvert;
@@ -92,6 +93,7 @@ export function computeCulvertLayout(
const minSample = Math.min(...sampleOffsets);
const maxSample = Math.max(...sampleOffsets);
if (!(maxSample > minSample)) return null;
const basinAdjust = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
// 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백).
const uphill = section.uphill_side ?? "left";
@@ -132,24 +134,16 @@ export function computeCulvertLayout(
: inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6
? "short"
: null;
// 사용자 선택(드롭다운 — 2026-08-22)이 규칙보다 우선한다:
// revet = 기슭막이+배관 강제(양측성토 로직), I/L/U = 해당 형식 집수정 강제.
const choice: InletStructureChoice = inletStructure ?? "auto";
const basinReason: BasinLayout["reason"] | null =
choice === "revet"
? null
: choice === "I" || choice === "L" || choice === "U"
? (ruleReason ?? "manual")
: ruleReason;
// 집수정 형식 기본값 = ㄴ(L)형(2026-08-20 확정) — 선택 시 그 형식.
const basinShape: BasinShape = choice === "I" || choice === "L" || choice === "U" ? choice : "L";
const { reason: basinReason, shape: basinShape } = resolveBasinChoice(choice, ruleReason);
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치(2026-08-22 확정) — 바닥(=관
// 유입 invert)이 따라 올라 물매도 바뀐다. I형은 원지반 배관 유지(상세 Basin 참조).
if (basinReason && basinShape !== "I") {
inlet.elevation = inletInfo.edge.elevation_m - BASIN_INNER_HEIGHT_M;
inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM;
} else if (basinReason) {
// I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내).
const startOffset = inlet.offset + inletInfo.outward * BASIN_INNER_WIDTH_M;
const startOffset =
inlet.offset + inletInfo.outward * (BASIN_INNER_WIDTH_M + basinAdjust.lateralM);
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
@@ -305,6 +299,7 @@ export function computeCulvertLayout(
edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge,
groundAt,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
adjust: basinAdjust,
});
basin = built.basin;
basinPipeEnd = built.pipeEnd;
@@ -317,6 +312,14 @@ export function computeCulvertLayout(
trimMin = built.trimOffset;
trimMinElevation = built.trimElevation;
}
const points = basinApproachPoints(inletInfo.edge, outward, basinAdjust, {
offset: built.trimOffset,
elevation: built.trimElevation,
});
if (points) {
if (outward > 0) trimMaxSlope = { points };
else trimMinSlope = { points };
}
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
@@ -611,7 +614,7 @@ export function computeCulvertLayout(
}
// I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 수평 되메움선(Basin 분리 — 700줄).
if (basin) {
if (basin && !(basinAdjust.lateralM > 1e-6 && basinAdjust.slopeM <= 1e-6)) {
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
}
// 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선.
@@ -105,6 +105,21 @@ export interface BasinLayout {
fillLine: { from: OffsetPoint; to: OffsetPoint } | null;
}
/** 측점별 집수정 조정값. 위치값은 기본 배치 기준이며 +는 계류측/사면 아래 방향이다. */
export interface BasinAdjust {
innerWidthM: number;
innerHeightM: number;
lateralM: number;
slopeM: number;
}
export const DEFAULT_BASIN_ADJUST: BasinAdjust = {
innerWidthM: 1.0,
innerHeightM: 1.2,
lateralM: 0,
slopeM: 0,
};
/** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */
export interface EndFace {
base: OffsetPoint;
@@ -7,6 +7,7 @@
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
@@ -33,7 +34,10 @@ export interface RevetOffsetControl {
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */
export interface InletStructureControl {
valueFor: (section: CrossSection) => InletStructureChoice;
adjustFor: (section: CrossSection) => BasinAdjust;
set: (chainageM: number, value: InletStructureChoice) => void;
updateAdjust: (chainageM: number, patch: Partial<BasinAdjust>) => void;
resetAdjust: (chainageM: number) => void;
}
/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
@@ -77,6 +81,7 @@ export function computeCardCulvert(
sourceSamples,
adjustsInput(section, revetOffset, extraWalls),
inletStructure?.valueFor(section),
inletStructure?.adjustFor(section),
equalizeExtras,
);
if (!layout) return null;
+16 -2
View File
@@ -543,8 +543,22 @@ export function appendCrossDesignOverlay(
| [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }]
| null => {
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
const minOffset = Math.max(trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity);
const maxOffset = Math.min(trim?.maxOffset ?? Infinity, meetRight ?? Infinity);
// 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서**
// 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서
// 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적:
// 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다).
const minSlopeStart = trim?.minSlope?.points[0]?.offset;
const maxSlopeStart = trim?.maxSlope?.points[0]?.offset;
const minOffset = Math.max(
trim?.minOffset ?? -Infinity,
meetLeft ?? -Infinity,
minSlopeStart ?? -Infinity,
);
const maxOffset = Math.min(
trim?.maxOffset ?? Infinity,
meetRight ?? Infinity,
maxSlopeStart ?? Infinity,
);
if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b];
const effective = { minOffset, maxOffset };
const lo = Math.min(a.offset_m, b.offset_m);
@@ -13,7 +13,7 @@
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { materialLimit, REVET_MATERIALS } from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { L } from "./B06_Section_UI_Section_Common";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -40,6 +40,9 @@ export interface StructurePanelDeps {
structureFor: () => InletStructureChoice;
/** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */
setStructure: (value: InletStructureChoice) => void;
basinAdjustFor: () => BasinAdjust;
updateBasin: (patch: Partial<BasinAdjust>) => void;
resetBasin: () => void;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
@@ -64,6 +67,7 @@ export interface StructurePanelHandle {
*/
const STEP_M = 1.0;
const HEIGHT_STEP_M = 0.1;
const BASIN_STEP_M = 0.1;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -227,6 +231,45 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
});
structureParts.controls.append(select);
const basinRow = makeRow("집수정 크기·위치");
basinRow.controls.classList.add("b06-structure-panel__basin-buttons");
const basinButton = (label: string, titleText: string, patch: () => Partial<BasinAdjust>) =>
makeButton(label, titleText, () => deps.updateBasin(patch()));
const basinHeightUp = basinButton("높이+", "내부 높이 +0.1m", () => ({
innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M),
}));
const basinHeightDown = basinButton("높이-", "내부 높이 -0.1m", () => ({
innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M),
}));
const basinWidthDown = basinButton("폭-", "내부 폭 -0.1m", () => ({
innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M),
}));
const basinWidthUp = basinButton("폭+", "내부 폭 +0.1m", () => ({
innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M),
}));
const basinSlopeUp = basinButton("사면▲", "1:1.2 성토선을 따라 위로", () => ({
slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M),
}));
const basinSlopeDown = basinButton("사면▼", "1:1.2 성토선을 따라 아래로", () => ({
slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M,
}));
const basinIn = basinButton("노견◀", "노견측으로 0.1m", () => ({
lateralM: Math.max(0, deps.basinAdjustFor().lateralM - BASIN_STEP_M),
}));
const basinOut = basinButton("바깥▶", "노견 반대측으로 0.1m", () => ({
lateralM: deps.basinAdjustFor().lateralM + BASIN_STEP_M,
}));
basinRow.controls.append(
basinHeightUp,
basinHeightDown,
basinWidthDown,
basinWidthUp,
basinSlopeUp,
basinSlopeDown,
basinIn,
basinOut,
);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +),
// 직접 입력 없음. 현재 단 수는 show()가 채운다.
const extraParts = makeRow(L("B06_Cross_Extra_Count"));
@@ -248,8 +291,19 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head, value, structureRow, materialRow, heightRow, extraRow, moveRow.row);
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
basinReset.classList.add("b06-structure-panel__basin-reset");
head.append(title, basinReset, closeButton);
root.append(
head,
value,
structureRow,
basinRow.row,
materialRow,
heightRow,
extraRow,
moveRow.row,
);
return {
root,
@@ -259,6 +313,9 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
if (!key) return;
const isExtra = key.startsWith("extra");
const movable = deps.canNudge(key);
const structure = deps.structureFor();
const isBasin =
key === "inlet" && (structure === "I" || structure === "L" || structure === "U");
title.textContent = isExtra
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
: key === "outlet"
@@ -269,7 +326,6 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet");
if (key === "inlet") {
const structure = deps.structureFor();
// 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은
// 남긴다 — 숨기면 셀렉트가 빈 값이 된다.
const allow = deps.optionsFor();
@@ -283,6 +339,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
select.value = structure;
}
moveRow.row.classList.toggle("is-hidden", !movable);
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
const isI = structure === "I";
for (const button of [basinHeightUp, basinHeightDown, basinWidthDown, basinWidthUp]) {
button.classList.toggle("is-hidden", isI);
}
materialRow.classList.toggle("is-hidden", !movable);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
+13
View File
@@ -550,6 +550,19 @@ export function createCrossSectionCard(
},
structureFor: () => inletStructure?.valueFor(section) ?? "auto",
setStructure: (value) => inletStructure?.set(section.chainage_m, value),
basinAdjustFor: () =>
inletStructure?.adjustFor(section) ?? {
innerWidthM: 1,
innerHeightM: 1.2,
lateralM: 0,
slopeM: 0,
},
updateBasin: (patch) =>
inletStructure?.updateAdjust(section.chainage_m, {
...inletStructure.adjustFor(section),
...patch,
}),
resetBasin: () => inletStructure?.resetAdjust(section.chainage_m),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !culvertInletIsBasin,
optionsFor: () => culvertInletOptions,
+18 -2
View File
@@ -151,7 +151,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
standard_cross_section: standardPanel?.getValues(),
});
if (designRequestSeq.get(chainageM) !== seq) return;
target.design = response.design;
target.design = {
...response.design,
inlet_structure: target.design?.inlet_structure,
basin_adjust: target.design?.basin_adjust,
};
sectionView.refreshCard(chainageM);
} catch (error) {
if (designRequestSeq.get(chainageM) !== seq) return;
@@ -236,7 +240,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (next) {
// full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다.
section.design = next as typeof section.design;
section.design = {
...(next as NonNullable<typeof section.design>),
inlet_structure: section.design?.inlet_structure,
basin_adjust: section.design?.basin_adjust,
};
sectionView.refreshCard(section.chainage_m);
}
}
@@ -387,6 +395,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const stationWidthControl = stationControls.stationWidth;
const revetOffsetControl = stationControls.revetOffset;
const stationWidths = stationControls.widths;
const inletStructures = stationControls.inletStructures;
const basinAdjustments = stationControls.basinAdjustments;
const sectionView = createSectionView(
(chainageM, change) => {
@@ -491,6 +501,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationWidths.forEach((width, chainage) => {
patchFor(Number(chainage)).display_half_width_m = width;
});
inletStructures.forEach((structure, chainage) => {
patchFor(Number(chainage)).inlet_structure = structure;
});
basinAdjustments.forEach((adjust, chainage) => {
patchFor(Number(chainage)).basin_adjust = adjust;
});
const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()];
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
const result =
@@ -7,8 +7,8 @@
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
ExtraWallControl,
InletStructureControl,
@@ -18,7 +18,9 @@ import type {
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
export interface StationControlDeps {
sessionKey: (kind: "crossw" | "revetx" | "inletstruct" | "extrawall") => string | null;
sessionKey: (
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall",
) => string | null;
refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null;
crossHalfWidth: () => number | undefined;
@@ -32,6 +34,8 @@ export interface StationControls {
inletStructure: InletStructureControl;
extraWalls: ExtraWallControl;
widths: Map<string, number>;
inletStructures: Map<string, InletStructureChoice>;
basinAdjustments: Map<string, BasinAdjust>;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
@@ -201,7 +205,32 @@ export function createStationControls(deps: StationControlDeps): StationControls
/* (2026-08-22 )
* auto()/revet(+)/I/L/U( ). . */
const inletStructures = new Map<string, InletStructureChoice>();
const basinAdjustments = new Map<string, BasinAdjust>();
const structSessionKey = (): string | null => deps.sessionKey("inletstruct");
const basinSessionKey = (): string | null => deps.sessionKey("basinadjust");
function loadBasinAdjustments(): void {
basinAdjustments.clear();
const key = basinSessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
BasinAdjust
>;
Object.entries(parsed).forEach(([chainage, value]) =>
basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persistBasinAdjustments(): void {
const key = basinSessionKey();
if (key)
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments)));
}
function loadInletStructures(): void {
inletStructures.clear();
@@ -300,13 +329,38 @@ export function createStationControls(deps: StationControlDeps): StationControls
};
const inletStructureControl: InletStructureControl = {
valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto",
valueFor: (section) =>
inletStructures.get(section.chainage_m.toFixed(2)) ??
section.design?.inlet_structure ??
"auto",
adjustFor: (section) =>
basinAdjustments.get(section.chainage_m.toFixed(2)) ?? {
...DEFAULT_BASIN_ADJUST,
...(section.design?.basin_adjust ?? {}),
},
set: (chainageM, value) => {
if (value === "auto") inletStructures.delete(chainageM.toFixed(2));
else inletStructures.set(chainageM.toFixed(2), value);
inletStructures.set(chainageM.toFixed(2), value);
persistInletStructures();
deps.refreshCard(chainageM);
},
updateAdjust: (chainageM, patch) => {
const key = chainageM.toFixed(2);
const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST };
const next = { ...current, ...patch };
basinAdjustments.set(key, {
innerWidthM: Math.min(2, Math.max(1, round1(next.innerWidthM))),
innerHeightM: Math.min(2, Math.max(1, round1(next.innerHeightM))),
lateralM: Math.max(0, clampMove(next.lateralM)),
slopeM: Math.max(0, clampMove(next.slopeM)),
});
persistBasinAdjustments();
deps.refreshCard(chainageM);
},
resetAdjust: (chainageM) => {
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
persistBasinAdjustments();
deps.refreshCard(chainageM);
},
};
return {
@@ -315,10 +369,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
inletStructure: inletStructureControl,
extraWalls: extraWallControl,
widths: stationWidths,
inletStructures,
basinAdjustments,
load: () => {
loadStationWidths();
loadRevetShifts();
loadInletStructures();
loadBasinAdjustments();
loadExtraCounts();
},
applyGlobalWidth: (requested, chainages) => {
@@ -107,6 +107,16 @@
gap: 2px;
}
.b06-structure-panel__basin-buttons {
display: grid;
grid-template-columns: repeat(4, max-content);
gap: 2px;
}
.b06-structure-panel__basin-reset {
margin-left: auto;
}
.b06-structure-panel__select {
flex: 1;
padding: 1px var(--spacing-4);