feat(B06): 유출부 보호공 삭제·성토부선 전환 + 추가 기슭막이(계단식)
- 보호공(돌붙임) 띠 삭제, 윗면 선만 성토부선(계획선)으로 유지 — 구간별
사면길이·5m 초과 여부 툴팁 표기
- 성토부 5m 이상이면 조정창 +로 추가 기슭막이(배관 없는 동일 형상 벽)
계단식 추가 — 자동 자리는 배관용과 같은 규칙(1:1.2 최소 자리)
- 추가 벽도 ◀/▶/↺ 이동(안쪽 한계 = 앞 구조물), 마지막 벽 - 삭제
- 개수·이동량 세션 보관(b06:extrawall / revetx extra{n} 키)
- 700줄 제한: Extra(성토부선·추가 벽)·Wire(카드 배선) 분리 신설
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,11 @@
|
||||
* 여기서는 계산 결과(`CulvertLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
|
||||
*
|
||||
* 그리는 순서 = 시공·계산 순서(2026-08-20 사용자 확정):
|
||||
* ① 유출구측 구조물(기슭막이) → ② 유입구측 구조물(기슭막이 또는 집수정)
|
||||
* → ③ 배관 → ④ 유출구측 보호공(돌붙임)
|
||||
* ① 유출구측 구조물(기슭막이·추가 기슭막이) → ② 유입구측 구조물(기슭막이 또는
|
||||
* 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
PITCHING_THICKNESS_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
@@ -29,6 +28,7 @@ export type {
|
||||
CulvertDesignTrim,
|
||||
CulvertLayout,
|
||||
EndFace,
|
||||
InletStructureChoice,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
@@ -47,8 +47,8 @@ function polygon(
|
||||
return shape;
|
||||
}
|
||||
|
||||
/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. */
|
||||
export type RevetKey = "inlet" | "outlet";
|
||||
/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */
|
||||
export type RevetKey = "inlet" | "outlet" | `extra${number}`;
|
||||
|
||||
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
|
||||
export type RevetHighlightSetter = (key: RevetKey | null) => void;
|
||||
@@ -69,45 +69,47 @@ export function appendCulvertOverlay(
|
||||
): RevetHighlightSetter {
|
||||
const { culvert, pipe, pipeCorners } = layout;
|
||||
const diameter = culvert.diameter_m;
|
||||
const roleLabel = (role: "inlet" | "outlet") => (role === "inlet" ? "유입" : "유출");
|
||||
const roleLabel = (role: "inlet" | "outlet" | "extra") =>
|
||||
role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)";
|
||||
|
||||
// ④ 유출구측 보호공(돌붙임) — 관까지 그린 뒤 마지막에 덮는다.
|
||||
const drawPitching = (): void => {
|
||||
const { top, base, lengthM, minLengthM } = layout.pitching;
|
||||
if (top.length < 2 || base.length !== top.length) return;
|
||||
layer.append(
|
||||
polygon(
|
||||
[
|
||||
...top.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
...[...base]
|
||||
.reverse()
|
||||
.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
],
|
||||
"b06-chart__culvert-pitching",
|
||||
`유출부 보호공(돌붙임) — 성토사면 보호, 단면 연장 ${lengthM.toFixed(1)}m` +
|
||||
` (최소 ${minLengthM.toFixed(1)}m = 낙차고×2 — 사방교본 교차 참조)` +
|
||||
` · t=${PITCHING_THICKNESS_M}m(실무 L3=45)` +
|
||||
` · 시작 변은 기슭막이 전면과 같은 경사, 윗면 시작점 = 관 하단 꼭짓점` +
|
||||
` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` +
|
||||
`(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` +
|
||||
` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` +
|
||||
`(1:1.2 고정${layout.fillSlope.ratioClamped ? " — 벗어남" : ""})` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` +
|
||||
`(기슭막이가 밖으로 나간 만큼 노면이 늘어난다)`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
// ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반.
|
||||
const drawOutletFill = (): void => {
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
if (segment.points.length < 2) continue;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute(
|
||||
"points",
|
||||
segment.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
// 성토부선은 공사 계획선 — 설계선과 같은 보라 실선(집수정 절토·성토선과 동일).
|
||||
line.setAttribute("class", "b06-chart__design-cross");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent =
|
||||
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: "");
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
}
|
||||
};
|
||||
|
||||
// ①·② 구조물 — 유출측이 먼저 서고, 그 다음 유입측.
|
||||
// ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측.
|
||||
const wallsInOrder = [
|
||||
...layout.walls.filter((w) => w.role === "outlet"),
|
||||
...layout.extraWalls,
|
||||
...layout.walls.filter((w) => w.role === "inlet"),
|
||||
];
|
||||
const keyOf = (wall: (typeof wallsInOrder)[number]): RevetKey =>
|
||||
wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role;
|
||||
const revetShapes = new Map<RevetKey, SVGPolygonElement>();
|
||||
/** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */
|
||||
const basinShapes: SVGPolygonElement[] = [];
|
||||
@@ -125,7 +127,7 @@ export function appendCulvertOverlay(
|
||||
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""),
|
||||
);
|
||||
if (onSelectRevet) revetShape.classList.add("is-selectable");
|
||||
revetShapes.set(wall.role, revetShape);
|
||||
revetShapes.set(keyOf(wall), revetShape);
|
||||
layer.append(revetShape);
|
||||
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
|
||||
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
|
||||
@@ -150,7 +152,7 @@ export function appendCulvertOverlay(
|
||||
// ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려
|
||||
// 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정).
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const clipId = `b06-revet-clip-${wall.role}-${Math.round(wall.backOffset * 100)}`;
|
||||
const clipId = `b06-revet-clip-${keyOf(wall)}-${Math.round(wall.backOffset * 100)}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
clip.append(
|
||||
@@ -312,7 +314,7 @@ export function appendCulvertOverlay(
|
||||
),
|
||||
);
|
||||
}
|
||||
drawPitching();
|
||||
drawOutletFill();
|
||||
|
||||
// 클릭 판정용 투명 겹면 — **맨 마지막에** 얹는다. 관·보호공이 벽 위를 지나가 그리기
|
||||
// 순서상 벽 가운데를 덮으므로, 벽 폴리곤에 직접 핸들러를 달면 가운데를 눌러도 관이
|
||||
@@ -327,7 +329,7 @@ export function appendCulvertOverlay(
|
||||
);
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onSelectRevet(wall.role);
|
||||
onSelectRevet(keyOf(wall));
|
||||
});
|
||||
layer.append(hit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Culvert_Extra.ts
|
||||
* 유출측 **성토부선 + 추가 기슭막이**(2026-08-22 사용자) — Geom에서 분리(700줄 제한).
|
||||
*
|
||||
* 종전 보호공(돌붙임) 띠는 삭제하고 그 **윗면 선만 성토부선**으로 남긴다. 관 하단
|
||||
* 꼭짓점에서 1:1.2로 원지반까지 내려가는 이 선이 5m 이상이면 기슭막이 의무 구간
|
||||
* (성토_비탈면.md §2)이라 사용자가 **추가 기슭막이**(배관 없는 벽)를 둘 수 있다.
|
||||
* 추가 벽 뒤에도 성토부가 5m 이상 남으면 또 추가할 수 있다(계단식).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
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";
|
||||
|
||||
export interface OutletExtrasInput {
|
||||
/** 성토부선 시작점 = 관 유출 하단 꼭짓점. */
|
||||
start: OffsetPoint;
|
||||
outward: number;
|
||||
groundAt: (offset: number) => number;
|
||||
/** 성토부 물매(1:n) — 설계 성토 물매(기본 1.2). */
|
||||
fillRatio: number;
|
||||
/** 추가 벽 표준 높이(배관용과 동일: 관경+여유고, 0.1m 눈금). */
|
||||
wallHeight: number;
|
||||
/** 형태별 높이 한계(찰 3.0/메 2.0). */
|
||||
heightLimit: number;
|
||||
form: string | null;
|
||||
/** 계류측 샘플 한계 offset. */
|
||||
limitOffset: number;
|
||||
/** 사용자가 민 이동량(m, + = 계류측 바깥) — 벽 개수만큼. */
|
||||
shifts: number[];
|
||||
}
|
||||
|
||||
export interface OutletExtrasResult {
|
||||
walls: WallLayout[];
|
||||
segments: OutletFillSegment[];
|
||||
/** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */
|
||||
appliedShifts: number[];
|
||||
/** 끝 성토부가 아직 5m 이상 — 추가 기슭막이를 더 둘 수 있다. */
|
||||
addable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토부선(끝 구간) — src에서 1:n으로 내려가며 원지반을 만나면 끝난다.
|
||||
* 지반이 사면보다 높은 구간은 지반을 따른다(종전 보호공 윗면과 동일 규칙,
|
||||
* 최소 길이 연장은 보호공 삭제와 함께 없앴다).
|
||||
*/
|
||||
function trailingFill(
|
||||
src: OffsetPoint,
|
||||
outward: number,
|
||||
ratio: number,
|
||||
groundAt: (offset: number) => number,
|
||||
): OutletFillSegment {
|
||||
const points: OffsetPoint[] = [src];
|
||||
let length = 0;
|
||||
let previous = src;
|
||||
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 groundElevation = groundAt(offset);
|
||||
const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) };
|
||||
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
|
||||
points.push(point);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 기슭막이 벽 1매 — 배관용 기슭막이와 **같은 형상**(배면 수직·전면 1:0.3,
|
||||
* 수평 기초 근입 0.5m), 배관·마감면만 없다(2026-08-22 사용자 ③).
|
||||
*/
|
||||
function buildExtraWall(
|
||||
index: number,
|
||||
anchor: OffsetPoint,
|
||||
outward: number,
|
||||
height: number,
|
||||
floatGapM: number,
|
||||
form: string | null,
|
||||
groundAt: (offset: number) => number,
|
||||
): WallLayout {
|
||||
const thickness = REVET_THICKNESS_M;
|
||||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const backOffset = anchor.offset - outward * (baseWidth / 2);
|
||||
const frontBase = anchor.offset + outward * (baseWidth / 2);
|
||||
const topJoint = backOffset + outward * (thickness / 2);
|
||||
const topElevation = anchor.elevation + height;
|
||||
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
|
||||
const topFront = topJoint + outward * thickness;
|
||||
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);
|
||||
}
|
||||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||||
const bottomFront: OffsetPoint = {
|
||||
offset: frontXAt(bottomElevation),
|
||||
elevation: bottomElevation,
|
||||
};
|
||||
return {
|
||||
role: "extra",
|
||||
extraIndex: index,
|
||||
form,
|
||||
lengthM: null,
|
||||
backOffset,
|
||||
outerOffset: bottomFront.offset,
|
||||
base: anchor.elevation,
|
||||
height,
|
||||
floatGapM,
|
||||
outward,
|
||||
topBack,
|
||||
topJoint: { offset: topJoint, elevation: topElevation },
|
||||
bottomBack,
|
||||
bottomFront,
|
||||
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유출측 성토부선·추가 기슭막이 일괄 계산.
|
||||
*
|
||||
* 벽 i의 자동 자리 = 앞 구간 시작점(src)을 노견 삼아 **1:1.2가 딱 성립하는 가장
|
||||
* 안쪽 자리**(배관용 기슭막이와 같은 규칙 — minShoulderWallOffset 재사용). 사용자가
|
||||
* 안쪽으로 당기면 높이 증가 → 한계 시 바닥 띄움(solveWallVertical 동일 사다리).
|
||||
* 벽 상단 구간은 src → 이음선 상단점 직선, 벽 다음 src = 전면 상단 꼭짓점.
|
||||
*/
|
||||
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
|
||||
const { outward, groundAt, fillRatio, wallHeight, heightLimit, form, limitOffset } = input;
|
||||
const walls: WallLayout[] = [];
|
||||
const segments: OutletFillSegment[] = [];
|
||||
const appliedShifts: number[] = [];
|
||||
let src = input.start;
|
||||
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,
|
||||
);
|
||||
walls.push(wall);
|
||||
// src → 벽 이음선 상단점 직선 구간. 물매가 1:1.2보다 급하면 경고 대상(overLimit
|
||||
// 아님 — 길이 기준만 적는다).
|
||||
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),
|
||||
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||||
});
|
||||
// 다음 성토부 시작점 = 벽 전면 상단 꼭짓점(points[2]).
|
||||
src = wall.points[2];
|
||||
}
|
||||
const tail = trailingFill(src, outward, fillRatio, groundAt);
|
||||
segments.push(tail);
|
||||
return { walls, segments, appliedShifts, addable: tail.overLimit };
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
|
||||
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무
|
||||
* 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3),
|
||||
* 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 보호공은 관 하단 꼭짓점부터
|
||||
* 원지반까지(최소 낙차고×2), 성토 경사선은 벽 접점에서 끊는다(designTrim).
|
||||
* 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 유출측은 관 하단 꼭짓점부터
|
||||
* 성토부선(보호공 삭제 — 2026-08-22), 성토 경사선은 벽 접점에서 끊는다(designTrim).
|
||||
* 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
FILL_SLOPE_RATIO_MAX,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
MIN_PIPE_COVER_M,
|
||||
PITCHING_THICKNESS_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
buildBasin,
|
||||
inletChoiceAvailability,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import {
|
||||
clampToFace,
|
||||
@@ -69,7 +69,7 @@ export function computeCulvertLayout(
|
||||
section: CrossSection,
|
||||
groundSamples: SectionSample[],
|
||||
/** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */
|
||||
revetShift?: { inlet?: number; outlet?: number },
|
||||
revetShift?: { inlet?: number; outlet?: number; extras?: number[] },
|
||||
/** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */
|
||||
inletStructure?: InletStructureChoice,
|
||||
): CulvertLayout | null {
|
||||
@@ -582,58 +582,21 @@ export function computeCulvertLayout(
|
||||
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
|
||||
}
|
||||
|
||||
// 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②:
|
||||
// ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다),
|
||||
// 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다).
|
||||
// ② 성토사면 경사(1:n)로 원지반까지 내려간 뒤에도 **최소 보호공 길이**
|
||||
// (낙차고×2 — 백엔드 `apron_length_m`)를 채울 때까지 원지반을 따라 더 간다.
|
||||
// 끝단 윗점이 원지반 위에 놓인다.
|
||||
const pitchingTop: OffsetPoint[] = [];
|
||||
const pitchingBase: OffsetPoint[] = [];
|
||||
let pitchingLength = 0;
|
||||
const minPitchingM = culvert.outlet.apron_length_m ?? 0;
|
||||
if (outletWall) {
|
||||
const fillRatio = section.design?.fill_slope_ratio ?? 1.2;
|
||||
// 밑면 이동 방향 = 기슭막이 전면 변과 나란한 **아래 방향** 단위벡터 × 두께.
|
||||
let shift: OffsetPoint = { offset: 0, elevation: -PITCHING_THICKNESS_M };
|
||||
const face = endFaces.outlet;
|
||||
if (face) {
|
||||
const faceLength = Math.hypot(face.direction.offset, face.direction.elevation);
|
||||
if (faceLength > 1e-9) {
|
||||
shift = {
|
||||
offset: (-face.direction.offset / faceLength) * PITCHING_THICKNESS_M,
|
||||
elevation: (-face.direction.elevation / faceLength) * PITCHING_THICKNESS_M,
|
||||
};
|
||||
}
|
||||
}
|
||||
const start = pipeCorners.outlet.bottom;
|
||||
pitchingTop.push(start);
|
||||
let previous = start;
|
||||
const step = 0.25;
|
||||
for (let t = step; t <= 30 + 1e-9; t += step) {
|
||||
const offset = start.offset + outletWall.outward * t;
|
||||
const slopeElevation = start.elevation - t / fillRatio;
|
||||
const groundElevation = groundAt(offset);
|
||||
const point: OffsetPoint = {
|
||||
offset,
|
||||
elevation: Math.max(slopeElevation, groundElevation),
|
||||
};
|
||||
pitchingLength += Math.hypot(
|
||||
point.offset - previous.offset,
|
||||
point.elevation - previous.elevation,
|
||||
);
|
||||
pitchingTop.push(point);
|
||||
previous = point;
|
||||
// 지반에 닿았고 최소 길이도 채웠으면 마감 — 아직이면 지반을 따라 계속 간다.
|
||||
if (slopeElevation <= groundElevation && pitchingLength >= minPitchingM - 1e-6) break;
|
||||
}
|
||||
for (const point of pitchingTop) {
|
||||
pitchingBase.push({
|
||||
offset: point.offset + shift.offset,
|
||||
elevation: point.elevation + shift.elevation,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로
|
||||
// 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다.
|
||||
const extras = outletWall
|
||||
? buildOutletExtras({
|
||||
start: pipeCorners.outlet.bottom,
|
||||
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,
|
||||
limitOffset: outletInfo.limit,
|
||||
shifts: revetShift?.extras ?? [],
|
||||
})
|
||||
: { walls: [], segments: [], appliedShifts: [], addable: false };
|
||||
|
||||
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
|
||||
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
|
||||
@@ -677,14 +640,10 @@ export function computeCulvertLayout(
|
||||
// 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다).
|
||||
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
|
||||
},
|
||||
pitching: {
|
||||
top: pitchingTop,
|
||||
base: pitchingBase,
|
||||
lengthM: pitchingLength,
|
||||
minLengthM: minPitchingM,
|
||||
},
|
||||
outletFill: { segments: extras.segments, addable: extras.addable },
|
||||
extraWalls: extras.walls,
|
||||
basin,
|
||||
revetShift: appliedShift,
|
||||
revetShift: { ...appliedShift, extras: extras.appliedShifts },
|
||||
designTrim:
|
||||
walls.length || basin
|
||||
? {
|
||||
|
||||
@@ -139,7 +139,7 @@ export interface FillSlopeSegment {
|
||||
* 접점이 다시 어긋난다.
|
||||
*/
|
||||
export function fillSlopeOf(
|
||||
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint; outward: number },
|
||||
wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number },
|
||||
edge: { offset_m: number; elevation_m: number },
|
||||
): FillSlopeSegment {
|
||||
const to = wall.topJoint;
|
||||
|
||||
@@ -27,7 +27,9 @@ export interface CulvertDesignTrim {
|
||||
}
|
||||
|
||||
export interface WallLayout {
|
||||
role: "inlet" | "outlet";
|
||||
role: "inlet" | "outlet" | "extra";
|
||||
/** 추가 기슭막이 번호(role === "extra"일 때만) — 조정창 키 `extra{n}`이 된다. */
|
||||
extraIndex?: number;
|
||||
form: string | null;
|
||||
lengthM: number | null;
|
||||
/** 배면(도로측) 바닥 offset. */
|
||||
@@ -93,13 +95,15 @@ export interface PipeEnd {
|
||||
top: OffsetPoint;
|
||||
}
|
||||
|
||||
/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */
|
||||
export interface PitchingLayout {
|
||||
top: OffsetPoint[];
|
||||
base: OffsetPoint[];
|
||||
/**
|
||||
* 유출측 **성토부선** 한 구간(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
* 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단)에서 1:1.2로 원지반(또는 다음 벽)까지.
|
||||
*/
|
||||
export interface OutletFillSegment {
|
||||
points: OffsetPoint[];
|
||||
lengthM: number;
|
||||
/** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */
|
||||
minLengthM: number;
|
||||
/** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
export interface CulvertLayout {
|
||||
@@ -127,8 +131,10 @@ export interface CulvertLayout {
|
||||
*/
|
||||
structureRequired: boolean;
|
||||
};
|
||||
/** 유출측 보호공 띠(없으면 top/base 빈 배열). */
|
||||
pitching: PitchingLayout;
|
||||
/** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */
|
||||
outletFill: { segments: OutletFillSegment[]; addable: boolean };
|
||||
/** 추가 기슭막이(배관 없는 벽 — 계단식). 조정창 키 = `extra{n}`. */
|
||||
extraWalls: WallLayout[];
|
||||
/** 유입 집수정 단면(있을 때만). */
|
||||
basin: BasinLayout | null;
|
||||
designTrim: CulvertDesignTrim | null;
|
||||
@@ -137,7 +143,7 @@ export interface CulvertLayout {
|
||||
* 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면
|
||||
* 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적).
|
||||
*/
|
||||
revetShift: { inlet: number; outlet: number };
|
||||
revetShift: { inlet: number; outlet: number; extras: number[] };
|
||||
/**
|
||||
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
|
||||
* revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Culvert_Wire.ts
|
||||
* 횡단 카드의 배수관 세트 **계산 호출 + 이동량 되받기(토스트)** — Cross_View에서
|
||||
* 분리(700줄 제한). 카드가 다시 그려질 때마다 한 번 불린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { CulvertLayout, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetOffsetControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { L } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/**
|
||||
* 배수관 세트 기하 계산 + 제어 상태 동기화.
|
||||
*
|
||||
* ① 세션의 이동량(기슭막이·추가 벽)·유입 구조물 선택을 모아 기하를 계산하고,
|
||||
* ② 성토부가 짧아져 못 세운 추가 벽은 개수를 잘라 동기화하며(고아 이동량 방지),
|
||||
* ③ 요청 이동량이 한계에 잘렸으면 실제 적용값으로 되돌려 담는다 — 안 그러면 눌러도
|
||||
* 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). 잘린 순간에는
|
||||
* **왜 안 움직였는지 토스트**로 알린다(2026-08-22 사용자 — 13+15.7처럼 자동
|
||||
* 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). 조작 중(그 벽이 선택된
|
||||
* 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵.
|
||||
*/
|
||||
export function computeCardCulvert(
|
||||
section: CrossSection,
|
||||
sourceSamples: SectionSample[],
|
||||
activeRevet: RevetKey | null,
|
||||
revetOffset?: RevetOffsetControl,
|
||||
inletStructure?: InletStructureControl,
|
||||
extraWalls?: ExtraWallControl,
|
||||
): CulvertLayout | null {
|
||||
const layout = computeCulvertLayout(
|
||||
section,
|
||||
sourceSamples,
|
||||
revetOffset
|
||||
? {
|
||||
inlet: revetOffset.shiftFor(section, "inlet"),
|
||||
outlet: revetOffset.shiftFor(section, "outlet"),
|
||||
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
|
||||
revetOffset.shiftFor(section, `extra${i}`),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
inletStructure?.valueFor(section),
|
||||
);
|
||||
if (!layout) return null;
|
||||
if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) {
|
||||
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length);
|
||||
}
|
||||
if (revetOffset) {
|
||||
const roles: Array<[RevetKey, number]> = [
|
||||
["inlet", layout.revetShift.inlet],
|
||||
["outlet", layout.revetShift.outlet],
|
||||
...layout.revetShift.extras.map((applied, i): [RevetKey, number] => [`extra${i}`, applied]),
|
||||
];
|
||||
for (const [role, applied] of roles) {
|
||||
const requested = revetOffset.shiftFor(section, role);
|
||||
if (activeRevet === role && Math.abs(requested - applied) > 0.05) {
|
||||
// 이동량 부호: + = 계류측 바깥. 요청이 적용보다 작으면 안쪽 요청이 잘린 것.
|
||||
showToast(
|
||||
L(requested < applied ? "B06_Cross_Revet_Limit_Inward" : "B06_Cross_Revet_Limit_Outward"),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
revetOffset.syncShift(section.chainage_m, role, applied);
|
||||
}
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
@@ -33,6 +33,12 @@ 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;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
@@ -80,6 +86,10 @@ 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(
|
||||
@@ -98,6 +108,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
L("B06_Cross_Revet_Reset"),
|
||||
act((key) => deps.reset(key)),
|
||||
),
|
||||
addButton,
|
||||
removeButton,
|
||||
);
|
||||
|
||||
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
|
||||
@@ -139,8 +151,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
current = key;
|
||||
root.classList.toggle("is-hidden", key === null);
|
||||
if (!key) return;
|
||||
title.textContent =
|
||||
key === "outlet"
|
||||
const isExtra = key.startsWith("extra");
|
||||
title.textContent = isExtra
|
||||
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
|
||||
: key === "outlet"
|
||||
? L("B06_Cross_Revet_Outlet")
|
||||
: deps.canNudge("inlet")
|
||||
? L("B06_Cross_Revet_Inlet")
|
||||
@@ -162,6 +176,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
select.value = current;
|
||||
}
|
||||
buttons.classList.toggle("is-hidden", !deps.canNudge(key));
|
||||
// +는 유출·추가 벽에서 끝 성토부가 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}`));
|
||||
const shift = deps.shiftFor(key);
|
||||
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
|
||||
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
sectionModeLabel,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
@@ -181,6 +181,16 @@ export interface InletStructureControl {
|
||||
set: (chainageM: number, value: InletStructureChoice) => void;
|
||||
}
|
||||
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22 사용자 — 성토부 5m 이상 계단식 추가). */
|
||||
export interface ExtraWallControl {
|
||||
countFor: (section: CrossSection) => number;
|
||||
add: (chainageM: number) => void;
|
||||
/** 마지막 추가 벽 삭제(그 벽의 이동량도 함께 지운다). */
|
||||
removeLast: (chainageM: number) => void;
|
||||
/** 기하가 실제로 세운 개수로 잘라 동기화(성토부가 없어져 못 세운 벽 정리). */
|
||||
syncCount: (chainageM: number, built: number) => void;
|
||||
}
|
||||
|
||||
export function createCrossSectionCard(
|
||||
section: CrossSection,
|
||||
selected: boolean,
|
||||
@@ -203,6 +213,8 @@ export function createCrossSectionCard(
|
||||
revetOffset?: RevetOffsetControl,
|
||||
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22). */
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
): CrossCardElement {
|
||||
// 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06).
|
||||
const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
|
||||
@@ -230,6 +242,8 @@ export function createCrossSectionCard(
|
||||
let culvertInletIsBasin = false;
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
|
||||
let culvertExtraState = { canAdd: false, count: 0 };
|
||||
/**
|
||||
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
|
||||
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
|
||||
@@ -467,17 +481,14 @@ export function createCrossSectionCard(
|
||||
toDisplayY,
|
||||
toggleArea,
|
||||
);
|
||||
// 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다.
|
||||
const culvertLayout: ReturnType<typeof computeCulvertLayout> = computeCulvertLayout(
|
||||
// 배수관 세트 기하 계산 + 이동량 되받기(토스트) — 분리 파일(Wire, 700줄 제한).
|
||||
const culvertLayout = computeCardCulvert(
|
||||
section,
|
||||
sourceSamples,
|
||||
revetOffset
|
||||
? {
|
||||
inlet: revetOffset.shiftFor(section, "inlet"),
|
||||
outlet: revetOffset.shiftFor(section, "outlet"),
|
||||
}
|
||||
: undefined,
|
||||
inletStructure?.valueFor(section),
|
||||
activeRevet,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
);
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
@@ -499,32 +510,16 @@ export function createCrossSectionCard(
|
||||
toDisplayY,
|
||||
);
|
||||
}
|
||||
// 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19).
|
||||
// 배수관 측점 세트 — 기존 도형 위에 추가만 한다(2026-08-19). 조정창이 쓸
|
||||
// 카드 상태(관 길이·집수정 여부·선택지·추가 벽 상태)를 여기서 받아 둔다.
|
||||
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
if (culvertLayout) culvertInletOptions = culvertLayout.inletOptions;
|
||||
// 요청한 이동량이 한계에 걸려 잘렸으면 그 값으로 되돌려 담는다 — 안 그러면 눌러도
|
||||
// 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적).
|
||||
// 잘린 순간에는 **왜 안 움직였는지 토스트로 알린다**(2026-08-22 사용자 요청 —
|
||||
// 13+15.7처럼 자동 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우).
|
||||
// 조작 중(그 벽이 선택된 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵.
|
||||
if (culvertLayout && revetOffset) {
|
||||
for (const role of ["inlet", "outlet"] as const) {
|
||||
const requested = revetOffset.shiftFor(section, role);
|
||||
const applied = culvertLayout.revetShift[role];
|
||||
if (activeRevet === role && Math.abs(requested - applied) > 0.05) {
|
||||
// 이동량 부호: + = 계류측 바깥. 요청이 적용보다 작으면 안쪽 요청이 잘린 것.
|
||||
showToast(
|
||||
L(
|
||||
requested < applied
|
||||
? "B06_Cross_Revet_Limit_Inward"
|
||||
: "B06_Cross_Revet_Limit_Outward",
|
||||
),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
revetOffset.syncShift(section.chainage_m, role, applied);
|
||||
}
|
||||
if (culvertLayout) {
|
||||
culvertInletOptions = culvertLayout.inletOptions;
|
||||
culvertExtraState = {
|
||||
canAdd: culvertLayout.outletFill.addable,
|
||||
count: culvertLayout.extraWalls.length,
|
||||
};
|
||||
}
|
||||
if (culvertLayout) {
|
||||
setRevetActive = appendCulvertOverlay(
|
||||
@@ -622,9 +617,21 @@ export function createCrossSectionCard(
|
||||
},
|
||||
structureFor: () => inletStructure?.valueFor(section) ?? "auto",
|
||||
setStructure: (value) => inletStructure?.set(section.chainage_m, value),
|
||||
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다.
|
||||
canNudge: (key) => key === "outlet" || !culvertInletIsBasin,
|
||||
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
|
||||
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) {
|
||||
activeRevet = "outlet";
|
||||
revetOffset?.select(section.chainage_m, "outlet");
|
||||
}
|
||||
extraWalls?.removeLast(section.chainage_m);
|
||||
},
|
||||
});
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
|
||||
@@ -411,6 +411,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationWidthControl,
|
||||
revetOffsetControl,
|
||||
stationControls.inletStructure,
|
||||
stationControls.extraWalls,
|
||||
);
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetOffsetControl,
|
||||
StationWidthControl,
|
||||
@@ -15,7 +16,7 @@ import type {
|
||||
|
||||
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
||||
export interface StationControlDeps {
|
||||
sessionKey: (kind: "crossw" | "revetx" | "inletstruct") => string | null;
|
||||
sessionKey: (kind: "crossw" | "revetx" | "inletstruct" | "extrawall") => string | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
crossHalfWidth: () => number | undefined;
|
||||
@@ -27,6 +28,7 @@ export interface StationControls {
|
||||
stationWidth: StationWidthControl;
|
||||
revetOffset: RevetOffsetControl;
|
||||
inletStructure: InletStructureControl;
|
||||
extraWalls: ExtraWallControl;
|
||||
widths: Map<string, number>;
|
||||
load: () => void;
|
||||
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
|
||||
@@ -202,6 +204,70 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ──
|
||||
* 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */
|
||||
const extraCounts = new Map<string, number>();
|
||||
const extraSessionKey = (): string | null => deps.sessionKey("extrawall");
|
||||
|
||||
function loadExtraCounts(): void {
|
||||
extraCounts.clear();
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, count]) => {
|
||||
if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistExtraCounts(): void {
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
const extraWallControl: ExtraWallControl = {
|
||||
countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0,
|
||||
add: (chainageM) => {
|
||||
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}`));
|
||||
persistRevetShifts();
|
||||
persistExtraCounts();
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
syncCount: (chainageM, built) => {
|
||||
const key = chainageM.toFixed(2);
|
||||
const count = extraCounts.get(key) ?? 0;
|
||||
if (built >= count) return;
|
||||
// 성토부가 없어져 못 세운 벽 — 개수·이동량을 세워진 만큼으로 자른다(고아 방지).
|
||||
if (built <= 0) extraCounts.delete(key);
|
||||
else extraCounts.set(key, built);
|
||||
for (let i = built; i < count; i += 1) revetShifts.delete(revetKey(chainageM, `extra${i}`));
|
||||
persistRevetShifts();
|
||||
persistExtraCounts();
|
||||
},
|
||||
};
|
||||
|
||||
const inletStructureControl: InletStructureControl = {
|
||||
valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto",
|
||||
set: (chainageM, value) => {
|
||||
@@ -216,11 +282,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
stationWidth: stationWidthControl,
|
||||
revetOffset: revetOffsetControl,
|
||||
inletStructure: inletStructureControl,
|
||||
extraWalls: extraWallControl,
|
||||
widths: stationWidths,
|
||||
load: () => {
|
||||
loadStationWidths();
|
||||
loadRevetShifts();
|
||||
loadInletStructures();
|
||||
loadExtraCounts();
|
||||
},
|
||||
applyGlobalWidth: (requested, chainages) => {
|
||||
stationWidths.clear();
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
|
||||
import {
|
||||
createCrossSectionCard,
|
||||
type ExtraWallControl,
|
||||
type InletStructureControl,
|
||||
type RevetOffsetControl,
|
||||
crossCardNaturalHeight,
|
||||
@@ -176,6 +177,8 @@ export function createSectionView(
|
||||
revetOffset?: RevetOffsetControl,
|
||||
/** 유입측 구조물 형식 선택(2026-08-22) — 조정창 드롭다운. */
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
@@ -448,6 +451,7 @@ export function createSectionView(
|
||||
stationWidth,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
);
|
||||
|
||||
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
|
||||
|
||||
@@ -606,6 +606,10 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b06-structure-panel__btn.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-structure-panel__buttons.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -251,6 +251,9 @@ export const ui_locales_b2 = {
|
||||
B06_Cross_Revet_Inlet: ["유입 기슭막이", "Inlet revetment"],
|
||||
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_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