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:
@@ -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이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
|
||||
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user