feat(B06): 다단 기슭막이 규칙 재정립 — 정확한 1:1.2·매몰 종단·단 수 입력

- 벽 사이 성토사면 = 정확히 1:1.2 — 벽 높이를 자리에서 역산(닫힌식),
  0.1m 눈금 올림 제거(물매 정확 유지 우선)
- 아랫단 상단은 윗단 하단(수평 기초) 관통 금지 — 안쪽 이동 한계
- 다음 단 성토선 시작 = 윗단 하단 +0.5m 수평선 × 전면 경사선(1:0.3) 교차점
  (배관 벽의 관 하단 꼭짓점과 같은 자리)
- 시작점이 원지반 아래(벽 0.5m 이상 매몰)면 성토 불필요 — 다단 종료
- 추가 방식 = 유출 벽 조정창의 단 수 숫자 입력(+/- 버튼 대체),
  지형 허용 단 수 초과 시 토스트로 가능한 단 수 안내 후 되는 만큼만 반영

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 13:39:21 +09:00
co-authored by Claude Opus 5
parent 9a58e3b1bd
commit 8167dd23c3
9 changed files with 203 additions and 125 deletions
+129 -82
View File
@@ -1,45 +1,47 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Extra.ts
* 유출측 **성토부선 + 추가 기슭막이**(2026-08-22 사용자) — Geom에서 분리(700줄 제한).
* 유출측 **성토부선 + 추가 기슭막이(다단)** — Geom에서 분리(700줄 제한).
*
* 종전 보호공(돌붙임) 띠는 삭제하고 그 **윗면 선만 성토부선**으로 남긴다. 관 하단
* 꼭짓점에서 1:1.2로 원지반까지 내려가는 이 선이 5m 이상이면 기슭막이 의무 구간
* (성토_비탈면.md §2)이라 사용자가 **추가 기슭막이**(배관 없는 벽)를 둘 수 있다.
* 추가 벽 뒤에도 성토부가 5m 이상 남으면 또 추가할 수 있다(계단식).
* 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다
* (2026-08-22 사용자 확정 규칙):
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다.
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
* · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료.
* ========================================================================== */
import {
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import {
clampWallOffset,
minShoulderWallOffset,
solveWallVertical,
} from "./B06_Section_UI_Cross_Culvert_Solve";
import type {
OffsetPoint,
OutletFillSegment,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
/** 추가 벽 최소 높이(m, 임시) — 이보다 낮아지는 자리(바깥)로는 밀 수 없다. */
export const EXTRA_MIN_HEIGHT_M = 0.5;
export interface OutletExtrasInput {
/** 성토부선 시작점 = 관 유출 하단 꼭짓점. */
/** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */
start: OffsetPoint;
/** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */
startBottomElevation: number;
outward: number;
groundAt: (offset: number) => number;
/** 성토부 물매(1:n) — 설계 성토 물매(기본 1.2). */
fillRatio: number;
/** 추가 벽 표준 높이(배관용과 동일: 관경+여유고, 0.1m 눈금). */
/** 표준 높이(관경+여유고) — 자동 자리는 이 높이가 딱 맞는 지점이다. */
wallHeight: number;
/** 형태별 높이 한계(찰 3.0/메 2.0). */
/** 형태별 높이 한계(찰 3.0/메 2.0) — 안쪽 당김 한계. */
heightLimit: number;
form: string | null;
/** 계류측 샘플 한계 offset. */
limitOffset: number;
/** 사용자가 민 이동량(m, + = 계류측 바깥) — 벽 개수만큼. */
/** 사용자가 민 이동량(m, + = 계류측 바깥) — 요청한 단 수만큼. */
shifts: number[];
}
@@ -48,19 +50,19 @@ export interface OutletExtrasResult {
segments: OutletFillSegment[];
/** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */
appliedShifts: number[];
/** 끝 성토부가 아직 5m 이상 — 추가 기슭막이를 더 둘 수 있다. */
/** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */
addable: boolean;
}
/**
* 성토부선(끝 구간) — src에서 1:n으로 내려가며 원지반을 만나면 끝난다.
* 지반이 사면보다 높은 구간은 지반을 따른다(종전 보호공 윗면과 동일 규칙,
* 최소 길이 연장은 보호공 삭제와 함께 없앴다).
*/
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
function trailingFill(
src: OffsetPoint,
outward: number,
ratio: number,
groundAt: (offset: number) => number,
): OutletFillSegment {
const points: OffsetPoint[] = [src];
@@ -69,7 +71,7 @@ function trailingFill(
const step = 0.25;
for (let t = step; t <= 30 + 1e-9; t += step) {
const offset = src.offset + outward * t;
const slopeElevation = src.elevation - t / ratio;
const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN;
const groundElevation = groundAt(offset);
const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) };
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
@@ -77,24 +79,24 @@ function trailingFill(
previous = point;
if (slopeElevation <= groundElevation) break;
}
return { points, lengthM: length, overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6 };
}
/** 성토부선이 원지반과 만나는 offset — 추가 벽 자동 자리 스캔의 사면 끝. */
function trailingToeOffset(segment: OutletFillSegment): number {
return segment.points[segment.points.length - 1].offset;
return {
points,
lengthM: length,
ratio: FILL_SLOPE_RATIO_MIN,
overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
};
}
/**
* 추가 기슭막이 벽 1매 — 배관용 기슭막이와 **같은 형상**(배면 수직·전면 1:0.3,
* 수평 기초 근입 0.5m), 배관·마감면만 없다(2026-08-22 사용자 ③).
* 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3,
* 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 —
* 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선).
*/
function buildExtraWall(
index: number,
anchor: OffsetPoint,
outward: number,
height: number,
floatGapM: number,
form: string | null,
groundAt: (offset: number) => number,
): WallLayout {
@@ -109,14 +111,9 @@ function buildExtraWall(
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation =
(floatGapM > 1e-6
? anchor.elevation
: Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation)) -
REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M);
}
Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation) - REVET_EMBED_DEPTH_M;
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M);
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -131,7 +128,7 @@ function buildExtraWall(
outerOffset: bottomFront.offset,
base: anchor.elevation,
height,
floatGapM,
floatGapM: 0,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -142,61 +139,111 @@ function buildExtraWall(
}
/**
* 유출측 성토부선·추가 기슭막이 일괄 계산.
* 유출측 성토부선·다단 기슭막이 일괄 계산.
*
* 벽 i의 자동 자리 = 앞 구간 시작점(src)을 노견 삼아 **1:1.2가 딱 성립하는 가장
* 안쪽 자리**(배관용 기슭막이와 같은 규칙 — minShoulderWallOffset 재사용). 사용자가
* 안쪽으로 당기면 높이 증가 → 한계 시 바닥 띄움(solveWallVertical 동일 사다리).
* 벽 상단 구간은 src → 이음선 상단점 직선, 벽 다음 src = 전면 상단 꼭짓점.
* 각 단의 벽 높이는 자리에서 역산한다 — 이음선 상단점이 src에서 내려오는
* **1:1.2 사면선 위에 정확히** 놓이는 높이. 닫힌식(D = 벽 중심~src 수평거리):
* 이음선 자리 R = D jointRun(h), 상단 = src.elev R/1.2 = 지반(x) + h
* → h = (src.elev 지반(x) (D 0.25t)/1.2) / (1 0.15/1.2)
* 자동 자리 = h가 표준 높이(관경+여유고)에 처음 닿는 **가장 안쪽 지점**.
* 이동 한계: 안쪽 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단)·높이 한계(찰3/메2),
* 바깥쪽 = 최소 높이 0.5m. 요청 단 수보다 지형이 허락하는 단이 적으면 되는
* 만큼만 세운다 — 조정창이 개수 차이를 보고 토스트로 가능한 단 수를 알린다.
*/
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, fillRatio, wallHeight, heightLimit, form, limitOffset } = input;
const { outward, groundAt, wallHeight, heightLimit, form, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
const walls: WallLayout[] = [];
const segments: OutletFillSegment[] = [];
const appliedShifts: number[] = [];
let src = input.start;
let prevBottom = input.startBottomElevation;
for (let i = 0; i < input.shifts.length; i += 1) {
const trailing = trailingFill(src, outward, fillRatio, groundAt);
// 성토부가 이미 원지반에 닿아 사실상 없으면 더 세울 수 없다 — 남은 벽은 버린다.
if (trailing.lengthM < 0.5) break;
const srcEdge = { offset_m: src.offset, elevation_m: src.elevation };
const toe = trailingToeOffset(trailing);
const auto =
minShoulderWallOffset(srcEdge, outward, wallHeight, groundAt, toe, limitOffset) ?? toe;
// 안쪽 당김 한계 = 구간 시작점(앞 구조물) — 그 안쪽은 앞 벽·관 몫이다.
const shifted = clampWallOffset(auto + outward * input.shifts[i], src.offset, outward);
appliedShifts.push(Math.round((shifted - auto) * outward * 10) / 10);
const vertical = solveWallVertical(
shifted,
srcEdge,
outward,
groundAt(shifted),
wallHeight,
heightLimit,
);
const wall = buildExtraWall(
i,
{ offset: shifted, elevation: vertical.baseElevation },
outward,
vertical.height,
vertical.floatGapM,
form,
groundAt,
);
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
if (groundAt(src.offset) >= src.elevation - 0.01) break;
const trailing = trailingFill(src, outward, groundAt);
if (trailing.lengthM < 0.7) break;
/** 자리 x(벽 하단 중점)에서 1:1.2를 정확히 지키는 벽 높이(닫힌식). */
const heightAt = (x: number): number => {
const run = (x - src.offset) * outward;
return (
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
heightDenominator
);
};
// 아랫단 상단이 윗단 하단을 넘지 않을 이음선 최소 수평거리(상단 ≤ 윗단 하단).
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
const feasible = (x: number): boolean => {
const h = heightAt(x);
return (
h >= EXTRA_MIN_HEIGHT_M - 1e-9 &&
h <= heightLimit + 1e-9 &&
(x - src.offset) * outward - jointRunOf(h) >= minJointRun - 1e-6
);
};
// 자동 자리 = 표준 높이가 처음 성립하는 가장 안쪽 지점(0.05m 스캔). 표준까지
// 못 크는 지형이면 조건을 만족하는 첫 자리로 폴백.
const step = 0.05;
const span = (limitOffset - src.offset) * outward;
let auto: number | null = null;
let fallback: number | null = null;
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
const x = src.offset + outward * t;
if (!feasible(x)) continue;
if (fallback === null) fallback = x;
if (heightAt(x) >= wallHeight - 1e-9) {
auto = x;
break;
}
}
const autoOffset = auto ?? fallback;
if (autoOffset === null) break; // 이 단은 세울 자리가 없다 — 되는 만큼만.
// 사용자 이동 반영 후, 안 되는 자리면 되는 쪽으로 0.05m씩 되돌린다
// (안쪽 위반 → 바깥으로, 바깥 위반(최소 높이 미달) → 안쪽으로).
let shifted = autoOffset + outward * input.shifts[i];
for (let pass = 0; pass < 400 && !feasible(shifted); pass += 1) {
const h = heightAt(shifted);
const tooInner =
h > heightLimit ||
(shifted - src.offset) * outward - jointRunOf(Math.min(h, heightLimit)) < minJointRun;
shifted += outward * (tooInner ? step : -step);
}
if (!feasible(shifted)) break;
appliedShifts.push(Math.round((shifted - autoOffset) * outward * 10) / 10);
const height = heightAt(shifted);
const anchor: OffsetPoint = { offset: shifted, elevation: groundAt(shifted) };
const wall = buildExtraWall(i, anchor, outward, height, form, groundAt);
walls.push(wall);
// src → 이음선 상단점 직선 구간. 물매가 1:1.2보다 급하면 경고 대상(overLimit
// 아님 — 길이 기준만 적는다).
// src → 이음선 상단점 — 정확히 1:1.2(높이 역산으로 보장).
const run = Math.abs(wall.topJoint.offset - src.offset);
const rise = src.elevation - wall.topJoint.elevation;
segments.push({
points: [src, wall.topJoint],
lengthM: Math.hypot(run, rise),
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
});
// 다음 성토부 시작점 = 벽 전면 상단 꼭짓점(points[2]).
src = wall.points[2];
// 다음 시작점 =하단 +0.5m 수평선과 전면 경사선(1:0.3)의 교차점.
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
}
const tail = trailingFill(src, outward, fillRatio, groundAt);
segments.push(tail);
return { walls, segments, appliedShifts, addable: tail.overLimit };
// 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요).
let addable = false;
if (groundAt(src.offset) < src.elevation - 0.01) {
const tail = trailingFill(src, outward, groundAt);
segments.push(tail);
addable = tail.overLimit;
}
return { walls, segments, appliedShifts, addable };
}
@@ -587,9 +587,9 @@ export function computeCulvertLayout(
const extras = outletWall
? buildOutletExtras({
start: pipeCorners.outlet.bottom,
startBottomElevation: outletWall.bottomBack.elevation,
outward: outletInfo.outward,
groundAt,
fillRatio: section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN,
wallHeight: wallHeightFor(culvert.outlet),
heightLimit: revetHeightLimit(culvert.outlet.revet_form),
form: culvert.outlet.revet_form ?? null,
@@ -102,6 +102,8 @@ export interface PipeEnd {
export interface OutletFillSegment {
points: OffsetPoint[];
lengthM: number;
/** 구간 물매(1:n) — 벽 사이 구간은 높이 역산으로 정확히 1.2다. */
ratio?: number;
/** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */
overLimit: boolean;
}
@@ -50,6 +50,14 @@ export function computeCardCulvert(
);
if (!layout) return null;
if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) {
// 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토
// 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다.
if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) {
showToast(
L("B06_Cross_Extra_Limit").replace("{n}", String(layout.extraWalls.length)),
"info",
);
}
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length);
}
if (revetOffset) {
@@ -33,12 +33,11 @@ export interface StructurePanelDeps {
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
/** 추가 기슭막이 상태 — 끝 성토부 5m 이상(canAdd)일 때만 +를 보인다. */
/** 다단 기슭막이 상태 — 유출 벽 선택 시 개수 입력 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 추가 기슭막이 하나 추가(유출 성토부 끝 구간에). */
addExtra: () => void;
/** 마지막 추가 기슭막이 삭제 — 마지막 벽이 선택된 상태에서만 버튼이 보인다. */
removeExtra: () => void;
/** 다단 기슭막이 단 수 지정(2026-08-22 사용자 — 숫자 입력). 지형이 허락하는
* 단 수보다 크면 기하가 되는 만큼만 세우고 토스트로 알린다. */
setExtraCount: (count: number) => void;
}
export interface StructurePanelHandle {
@@ -86,10 +85,6 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
if (current) run(current);
};
// 추가 기슭막이 /(2026-08-22 사용자 — 성토부 5m 이상이면 계단식 추가).
const addButton = makeButton("", L("B06_Cross_Extra_Add"), () => deps.addExtra());
const removeButton = makeButton("", L("B06_Cross_Extra_Remove"), () => deps.removeExtra());
const buttons = document.createElement("div");
buttons.className = "b06-structure-panel__buttons";
buttons.append(
@@ -108,10 +103,26 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
L("B06_Cross_Revet_Reset"),
act((key) => deps.reset(key)),
),
addButton,
removeButton,
);
// 다단 기슭막이 단 수 입력(2026-08-22 사용자) — 유출 벽 기준 숫자 입력.
const extraRow = document.createElement("label");
extraRow.className = "b06-structure-panel__struct";
const extraLabel = document.createElement("span");
extraLabel.textContent = L("B06_Cross_Extra_Count");
const countInput = document.createElement("input");
countInput.type = "number";
countInput.min = "0";
countInput.max = "9";
countInput.step = "1";
countInput.className = "b06-structure-panel__count";
countInput.addEventListener("change", () => {
const requested = Math.max(0, Math.min(9, Math.round(Number(countInput.value) || 0)));
countInput.value = String(requested);
deps.setExtraCount(requested);
});
extraRow.append(extraLabel, countInput);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureRow = document.createElement("label");
structureRow.className = "b06-structure-panel__struct";
@@ -143,7 +154,7 @@ 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, buttons);
root.append(head, value, structureRow, extraRow, buttons);
return {
root,
@@ -176,10 +187,13 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
select.value = current;
}
buttons.classList.toggle("is-hidden", !deps.canNudge(key));
// +는 유출·추가 벽에서 끝 성토부가 5m 이상일 때, -는 마지막 추가 벽에서만.
// 단 수 입력은 **유출 벽에서만** — 성토부가 5m 이상(의무)이거나 이미 단이 있을 때.
const extra = deps.extraState();
addButton.classList.toggle("is-hidden", !(extra.canAdd && (key === "outlet" || isExtra)));
removeButton.classList.toggle("is-hidden", !(isExtra && key === `extra${extra.count - 1}`));
extraRow.classList.toggle(
"is-hidden",
!(key === "outlet" && (extra.canAdd || extra.count > 0)),
);
countInput.value = String(extra.count);
const shift = deps.shiftFor(key);
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
+9 -12
View File
@@ -181,13 +181,12 @@ export interface InletStructureControl {
set: (chainageM: number, value: InletStructureChoice) => void;
}
/** 유출측 추가 기슭막이 수 제어(2026-08-22 사용자 — 성토부 5m 이상 계단식 추가). */
/** 유출측 다단 기슭막이 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
export interface ExtraWallControl {
countFor: (section: CrossSection) => number;
add: (chainageM: number) => void;
/** 마지막 추가 벽 삭제(그 벽의 이동량도 함께 지운다). */
removeLast: (chainageM: number) => void;
/** 기하가 실제로 세운 개수로 잘라 동기화(성토부가 없어져 못 세운 벽 정리). */
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
setCount: (chainageM: number, count: number) => void;
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
syncCount: (chainageM: number, built: number) => void;
}
@@ -621,16 +620,14 @@ export function createCrossSectionCard(
canNudge: (key) => key !== "inlet" || !culvertInletIsBasin,
optionsFor: () => culvertInletOptions,
extraState: () => culvertExtraState,
addExtra: () => extraWalls?.add(section.chainage_m),
removeExtra: () => {
// 삭제되는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시 그려질 때
// 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다.
const last = `extra${culvertExtraState.count - 1}` as RevetKey;
if (activeRevet === last) {
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시
// 그려질 때 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다.
if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) {
activeRevet = "outlet";
revetOffset?.select(section.chainage_m, "outlet");
}
extraWalls?.removeLast(section.chainage_m);
extraWalls?.setCount(section.chainage_m, count);
},
});
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
@@ -237,20 +237,17 @@ export function createStationControls(deps: StationControlDeps): StationControls
const extraWallControl: ExtraWallControl = {
countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0,
add: (chainageM) => {
setCount: (chainageM, requested) => {
const key = chainageM.toFixed(2);
extraCounts.set(key, (extraCounts.get(key) ?? 0) + 1);
persistExtraCounts();
deps.refreshCard(chainageM);
},
removeLast: (chainageM) => {
const key = chainageM.toFixed(2);
const count = extraCounts.get(key) ?? 0;
if (count <= 0) return;
if (count === 1) extraCounts.delete(key);
else extraCounts.set(key, count - 1);
// 지운 벽의 이동량도 함께 지운다 — 다시 추가하면 자동 자리에서 시작한다.
revetShifts.delete(revetKey(chainageM, `extra${count - 1}`));
const previous = extraCounts.get(key) ?? 0;
const count = Math.max(0, Math.min(9, Math.round(requested)));
if (count === previous) return;
if (count === 0) extraCounts.delete(key);
else extraCounts.set(key, count);
// 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다.
for (let i = count; i < previous; i += 1) {
revetShifts.delete(revetKey(chainageM, `extra${i}`));
}
persistRevetShifts();
persistExtraCounts();
deps.refreshCard(chainageM);
@@ -610,6 +610,16 @@
display: none;
}
.b06-structure-panel__count {
width: 3.5em;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
}
.b06-structure-panel__buttons.is-hidden {
display: none;
}
+5 -2
View File
@@ -252,8 +252,11 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"],
B06_Cross_Struct_Label: ["구조물 형식", "Structure type"],
B06_Cross_Revet_Extra: ["추가 기슭막이", "Extra revetment"],
B06_Cross_Extra_Add: ["기슭막이 추가 — 성토부 5m 이상", "Add revetment (fill slope ≥ 5m)"],
B06_Cross_Extra_Remove: ["마지막 추가 기슭막이 삭제", "Remove last extra revetment"],
B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"],
B06_Cross_Extra_Limit: [
"지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다",
"Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+",
],
B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"],
B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"],
B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"],