feat(B06): 상황별 구조물 선택지 숨김 + 집수정 I형 원지반 근입

- 선택지 가용성(inletChoiceAvailability — Basin 분리):
  · 기슭막이+배관: 자동 자리 원지반이 토피 상한보다 높아 배관이 묻힐
    수밖에 없으면 숨김(2026-08-22 사용자 ①)
  · 집수정 ㄴ·ㄷ형: ㄴ형 가정으로 세워 봐서 원지반으로 밀고 들어가지
    않으면(절토선 없음) 숨김(사용자 ②)
  현재 선택된 값은 숨기지 않는다(빈 셀렉트 방지). 자동 자리 스캔은
  가용성 판정과 공용(중복 제거 — Geom 698줄).
- 집수정 I형 벽 하단을 원지반까지 근입(지반 아래 부재두께 0.2m) 후
  배관 배치(2026-08-22 사용자 — ㄴ·ㄷ형은 현행 유지). 벽면 기울기
  그대로 연장, 관 끝 마감면은 벽 폴리곤 실제 변 복사로 일반화.
- 검증: 13+15.7 I형 근입 0.40m·관 유입 불변(539.141), L형 회귀,
  dz 스윕(-2/-1: revet 숨김, +1: ㄴㄷ 숨김), 실화면 4+4.3 L·U 숨김
  / 13+15.7 revet 숨김(절토향 오르막 — 관 묻힘 판정) 드롭다운 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:45:09 +09:00
co-authored by Claude Opus 5
parent 11429654c1
commit 8743ccf810
5 changed files with 122 additions and 36 deletions
@@ -18,6 +18,7 @@ import {
REVET_FREEBOARD_M,
REVET_LEAN_RATIO,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve";
import type {
BasinLayout,
BasinShape,
@@ -72,25 +73,44 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
: BASIN_INNER_HEIGHT_M;
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20).
const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => {
// bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용).
const defaultBottom = anchor.elevation - floorThickness;
const wallOf = (
baseOffset: number,
dir: number,
bottomElevation = defaultBottom,
): OffsetPoint[] => {
const topShift = -dir * REVET_LEAN_RATIO * wallHeight;
const spanZ = wallHeight + floorThickness;
const xAt = (elevation: number): number =>
baseOffset + topShift * ((elevation - defaultBottom) / spanZ);
return [
{ offset: baseOffset, elevation: anchor.elevation - floorThickness },
{ offset: xAt(bottomElevation), elevation: bottomElevation },
{ offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight },
{
offset: baseOffset + topShift + dir * memberT,
elevation: anchor.elevation + wallHeight,
},
{
offset: baseOffset + dir * memberT,
elevation: anchor.elevation - floorThickness,
offset: xAt(bottomElevation) + dir * memberT,
elevation: bottomElevation,
},
];
};
// I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자
// 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥.
const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight);
const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }];
// I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼
// 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지.
const iWallBottom =
shape === "I"
? Math.min(anchor.elevation, groundAt(wallBase), groundAt(wallBase + outward * memberT)) -
floorThickness
: defaultBottom;
const parts: BasinLayout["parts"] = [
{ kind: "wall", points: wallOf(wallBase, outward, iWallBottom) },
];
const iWallPoints = parts[0].points;
// ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다
// (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다.
const wallOuterAt = (elevation: number): number => {
@@ -172,15 +192,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
fillLine: basinFill,
},
// 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정).
// 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영.
endFace: {
base: {
offset: wallBase + outward * memberT,
elevation: anchor.elevation - floorThickness,
},
// 변 전체(바닥−두께 ~ 상단) 기울기 — x 이동량은 벽 기움(0.3×벽높이).
base: iWallPoints[3],
direction: {
offset: -outward * REVET_LEAN_RATIO * wallHeight,
elevation: wallHeight + floorThickness,
offset: iWallPoints[2].offset - iWallPoints[3].offset,
elevation: iWallPoints[2].elevation - iWallPoints[3].elevation,
},
},
pipeEnd:
@@ -191,3 +208,45 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
trimElevation: anchor.elevation + wallHeight,
};
}
/**
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
* · 기슭막이+배관: 자동 자리의 원지반이 토피 상한(노면−관경−토피)보다 높으면 배관이
* 원지반에 묻힐 수밖에 없다 → 숨김.
* · 집수정 ㄴ·ㄷ형: ㄴ형으로 가정해 세워 봤을 때 구조물이 원지반으로 밀고 들어가지
* 않으면(절토선이 안 생기면) → 숨김.
*/
export function inletChoiceAvailability(input: {
designAt: ((offset: number) => number) | null;
groundAt: (offset: number) => number;
edge: { offset_m: number; elevation_m: number };
outward: number;
limitOffset: number;
wallHeight: number;
invertCapM: number;
diameterM: number;
cutSlopeRatio: number;
}): { revetAllowed: boolean; basinLUAllowed: boolean; revetAutoOffset: number } {
const { designAt, groundAt, edge, outward, limitOffset, wallHeight, invertCapM } = input;
let revetAllowed = true;
// 기슭막이 자동 자리(노견 최소 지점 ?? 사면 끝) — 본체가 벽 배치에도 재사용한다.
let revetAutoOffset = edge.offset_m;
if (designAt) {
const baseAt = (offset: number): number => Math.min(groundAt(offset), invertCapM);
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limitOffset);
revetAutoOffset =
minShoulderWallOffset(edge, outward, wallHeight, baseAt, toe, limitOffset) ?? toe;
revetAllowed = groundAt(revetAutoOffset) <= invertCapM + 0.02;
}
const probe = buildBasin({
anchor: { offset: edge.offset_m, elevation: edge.elevation_m - BASIN_INNER_HEIGHT_M },
outward,
shape: "L",
reason: "short",
diameterM: input.diameterM,
edge,
groundAt,
cutSlopeRatio: input.cutSlopeRatio,
});
return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset };
}
@@ -1,18 +1,11 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드가 만든 `section.culvert` 제원을 좌표로 옮긴다 — 치수를 새로 정하지 않는다.
*
* 배치 규칙 — 실무 횡단도 4장 기준(2026-08-20 사용자 제공, 울진 계열 주기):
* · 기슭막이 2기 — 유입/유출측 성토사면 안. 사면 쪽으로 기운 평행사변형(전면 1:0.3,
* 두께 0.45m). 자리는 사면선이 이음선 상단점을 지나도록 풀어서 정한다.
* · 배관 — 두 벽 사이(도로 하부)만 직선. 끝단면은 벽 전면과 평행하게 마감(수평 가능).
* · 보호공(돌붙임) — 관 하단 꼭짓점에서 성토사면 경사를 따라 원지반까지, 최소 길이
* (낙차고×2)를 채울 때까지 지반을 따라 더 간다. 유입측은 수평 보호공 없음.
* · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다).
*
* 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는
* 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다.
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무
* 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3),
* 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 보호공은 관 하단 꼭짓점부터
* 원지반까지(최소 낙차고×2), 성토 경사선은 벽 접점에서 끊는다(designTrim).
* 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
@@ -45,7 +38,11 @@ import type {
export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
import { BASIN_INNER_WIDTH_M, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
import {
BASIN_INNER_WIDTH_M,
buildBasin,
inletChoiceAvailability,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampToFace,
@@ -158,6 +155,18 @@ export function computeCulvertLayout(
// 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로).
const wallHeightFor = (spec: CulvertSideSpec): number =>
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
// 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한).
const inletOptions = inletChoiceAvailability({
designAt,
groundAt,
edge: inletInfo.edge,
outward: inletInfo.outward,
limitOffset: inletInfo.limit,
wallHeight: wallHeightFor(culvert.inlet),
invertCapM: invertCap(inletInfo.edge),
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */
const appliedShift = { inlet: 0, outlet: 0 };
/** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */
@@ -172,16 +181,8 @@ export function computeCulvertLayout(
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
const inletToe = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit);
const inletFeasible = minShoulderWallOffset(
inletInfo.edge,
inletInfo.outward,
wallHeightFor(culvert.inlet),
inletBaseAt,
inletToe,
inletInfo.limit,
);
inletAutoOffset = inletFeasible ?? inletToe;
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 안쪽 당김 한계 = **노견**(도로 밑 침범 금지). 1:1.2 자리 안쪽으로 당기면
// ① 높이 증가 ② 한계 도달 시 바닥 띄움으로 노견·물매를 지킨다
// (2026-08-22 사용자 재확정 — 13+15.7 확인 요청).
@@ -654,6 +655,7 @@ export function computeCulvertLayout(
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
inletOptions,
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
pipeAxis: { axis, normal },
@@ -138,4 +138,10 @@ export interface CulvertLayout {
* 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적).
*/
revetShift: { inlet: number; outlet: number };
/**
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
* revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만),
* basinLUAllowed = 집수정 ㄴ·ㄷ형(구조물이 원지반으로 밀고 들어갈 때만).
*/
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
}
@@ -31,6 +31,8 @@ export interface StructurePanelDeps {
setStructure: (value: InletStructureChoice) => void;
/** ◀/▶ 이동 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
}
export interface StructurePanelHandle {
@@ -145,7 +147,20 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
: L("B06_Cross_Struct_InletBasin");
// 형식 선택은 유입측에서만, ◀/▶/↺는 자리를 옮길 수 있는 구조물(기슭막이)만.
structureRow.classList.toggle("is-hidden", key !== "inlet");
if (key === "inlet") select.value = deps.structureFor();
if (key === "inlet") {
const current = deps.structureFor();
// 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은
// 남긴다 — 숨기면 셀렉트가 빈 값이 된다.
const allow = deps.optionsFor();
for (const option of select.options) {
const value = option.value as InletStructureChoice;
const hidden =
(value === "revet" && !allow.revetAllowed) ||
((value === "L" || value === "U") && !allow.basinLUAllowed);
option.hidden = hidden && value !== current;
}
select.value = current;
}
buttons.classList.toggle("is-hidden", !deps.canNudge(key));
const shift = deps.shiftFor(key);
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
+4
View File
@@ -228,6 +228,8 @@ export function createCrossSectionCard(
let culvertPipeLengthM: number | null = null;
/** 유입측이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */
let culvertInletIsBasin = false;
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
@@ -500,6 +502,7 @@ export function createCrossSectionCard(
// 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19).
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) culvertInletOptions = culvertLayout.inletOptions;
// 요청한 이동량이 한계에 걸려 잘렸으면 그 값으로 되돌려 담는다 — 안 그러면 눌러도
// 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적).
// 잘린 순간에는 **왜 안 움직였는지 토스트로 알린다**(2026-08-22 사용자 요청 —
@@ -621,6 +624,7 @@ export function createCrossSectionCard(
setStructure: (value) => inletStructure?.set(section.chainage_m, value),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다.
canNudge: (key) => key === "outlet" || !culvertInletIsBasin,
optionsFor: () => culvertInletOptions,
});
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
showRevetControl(activeRevet !== null);