feat(B06): 집수정 9키 조작 · 좌우 화면 기준 통일 · 계류측 성토부 체계 도입

① 집수정 조작을 9키(3×3)로 — 십자 축은 이동(◀▶ 좌우 / ▲▼ 사면 대각),
   네 모서리는 크기(⇕ 내부 높이 · ⇔ 내부 폭), 가운데는 초기화. 기슭막이
   십자와 같은 축 규칙·버튼 크기라 행열이 맞는다. I형은 내공 개념이 없어
   네 모서리만 숨긴다.
② 좌우 반전 해소 — 집수정 lateralM이 계류측(outward) 기준값에 그대로 묶여
   유입이 우측인 측점에서 ◀가 화면 오른쪽으로 갔다. 버튼을 **화면 기준**으로
   받아 카드가 부호를 환산한다. 노견 안쪽(음수)은 0에서 멈추고 토스트로 알린다.
③ 집수정 계류측에 **유출측과 같은 성토부 체계** 적용(ㄴ·ㄷ형) — 구조물 계류측
   끝이 원지반 위로 나오면 1:1.2 성토부선을 긋고, 5m를 넘으면 다단 기슭막이를
   둘 수 있다(키 , 단 수 행 별도). 원지반 안이면 종전 절토선,
   I형은 관 하단 3도 되메움선이 맡는다. 좌우 이동 시 마감선을 억제하던 임시
   조건은 제거했다(급경사 선의 근본 원인은 이미 설계선 트림에서 해결).
- 700줄 유지: attachBasin(Basin)·buildExtrasAt(Extra) 추출 + 주석 정리

검증: 4+4.3 ㄴ형 좌우 2m — 성토부선 2.41m가 원지반에 0.001m 오차로 접함
(종전 마감선 없음) · ◀ 클릭 시 4+4.3(유입 좌)·13+15.7(유입 우) 모두 화면
왼쪽으로 이동 · 9키 3×3 좌표 정렬 확인 · tsc·ruff·prettier 통과 ·
pytest 148 passed, 7 skipped · B06_Section 700줄 초과 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:59:09 +09:00
co-authored by Claude Opus 5
parent 2a247184fd
commit 917cdce5df
10 changed files with 368 additions and 166 deletions
+41 -13
View File
@@ -17,7 +17,12 @@ import {
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import type { BasinShape, CulvertLayout, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Geom";
import type {
BasinShape,
CulvertLayout,
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
@@ -48,7 +53,7 @@ function polygon(
}
/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */
export type RevetKey = "inlet" | "outlet" | `extra${number}`;
export type RevetKey = "inlet" | "outlet" | `extra${number}` | `bextra${number}`;
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type RevetHighlightSetter = (key: RevetKey | null) => void;
@@ -113,6 +118,19 @@ export function appendCulvertOverlay(
: ""),
);
}
// 집수정(ㄴ·ㄷ형) 계류측 성토부선 — 유출측과 같은 체계(2026-08-22 사용자 확정).
for (const segment of layout.basinFill.segments) {
planLine(
segment.points,
segment.kind === "cut"
? `집수정 계류측 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `집수정 계류측 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
}
// 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
if (layout.inletFill) {
planLine(
@@ -125,17 +143,27 @@ export function appendCulvertOverlay(
};
// ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측.
const wallsInOrder = [
...layout.walls.filter((w) => w.role === "outlet"),
...layout.extraWalls,
...layout.walls.filter((w) => w.role === "inlet"),
// 다단 벽은 유출측(extra)·집수정측(bextra) 두 갈래라 **출처를 키에 담아** 돈다.
const wallsInOrder: Array<{ wall: WallLayout; key: RevetKey }> = [
...layout.walls
.filter((w) => w.role === "outlet")
.map((wall) => ({ wall, key: "outlet" as RevetKey })),
...layout.extraWalls.map((wall) => ({
wall,
key: `extra${wall.extraIndex ?? 0}` as RevetKey,
})),
...layout.basinExtras.map((wall) => ({
wall,
key: `bextra${wall.extraIndex ?? 0}` as RevetKey,
})),
...layout.walls
.filter((w) => w.role === "inlet")
.map((wall) => ({ wall, key: "inlet" as RevetKey })),
];
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[] = [];
for (const wall of wallsInOrder) {
for (const { wall, key: wallKey } of wallsInOrder) {
// 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②).
const revetShape = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
@@ -149,7 +177,7 @@ export function appendCulvertOverlay(
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""),
);
if (onSelectRevet) revetShape.classList.add("is-selectable");
revetShapes.set(keyOf(wall), revetShape);
revetShapes.set(wallKey, revetShape);
layer.append(revetShape);
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
@@ -174,7 +202,7 @@ export function appendCulvertOverlay(
// ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려
// 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정).
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const clipId = `b06-revet-clip-${keyOf(wall)}-${Math.round(wall.backOffset * 100)}`;
const clipId = `b06-revet-clip-${wallKey}-${Math.round(wall.backOffset * 100)}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
clip.append(
@@ -342,7 +370,7 @@ export function appendCulvertOverlay(
// 먼저 먹는다(2026-08-21 화면 확인). 그리기 순서(벽 → 관 → 보호공)는 그대로 두고
// 판정면만 위로 올린다. 관 자체의 툴팁은 벽 밖 구간에서 그대로 뜬다.
if (onSelectRevet) {
for (const wall of wallsInOrder) {
for (const { wall, key: wallKey } of wallsInOrder) {
const hit = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
@@ -350,7 +378,7 @@ export function appendCulvertOverlay(
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectRevet(keyOf(wall));
onSelectRevet(wallKey);
});
layer.append(hit);
}
@@ -94,14 +94,20 @@ export interface BasinBuildResult {
/** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */
trimOffset: number;
trimElevation: number;
/**
* 계류측 성토부선 시작점(2026-08-22 사용자 확정 — 유출측과 같은 체계).
* ㄴ·ㄷ형에서 구조물 계류측 끝이 **원지반 위**로 나올 때만 준다. 그 자리에서
* 1:1.2 성토부선을 긋고 5m를 넘으면 다단 기슭막이를 둘 수 있다. 원지반 안으로
* 박히면 종전대로 절토선(cutLine)이고, I형은 관 하단 되메움선이 맡는다.
*/
fillStart: OffsetPoint | null;
/** 구조물 바닥 표고 — 다단 1단이 이 아래를 뚫지 못하게 하는 기준. */
fillBottomElevation: number;
}
/** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */
export const BASIN_INNER_WIDTH_M = 1.0;
/** 집수정 주변 성토(되메움) 기울기(1:n) — 성토 비탈 기본 1:1.2. */
const BASIN_FILL_SLOPE_RATIO = 1.2;
/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */
export function buildBasin(input: BasinBuildInput): BasinBuildResult {
const {
@@ -223,7 +229,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
// · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선**
// · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지.
let basinCut: BasinLayout["cutLine"] = null;
let basinFill: BasinLayout["cutLine"] = null;
let fillStart: OffsetPoint | null = null;
if (shape !== "I") {
const refTop =
shape === "U"
@@ -241,19 +247,10 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
break;
}
}
} else if (
groundHere < refTop.elevation - 0.05 &&
!(adjust.lateralM > 1e-6 && adjust.slopeM <= 1e-6)
) {
const dropPerM = 1 / BASIN_FILL_SLOPE_RATIO;
for (let run = 0.05; run <= 30; run += 0.05) {
const probe = refTop.offset + outward * run;
const elevation = refTop.elevation - dropPerM * run;
if (elevation <= groundAt(probe)) {
basinFill = { from: refTop, to: { offset: probe, elevation } };
break;
}
}
} else if (groundHere < refTop.elevation - 0.05) {
// 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로
// 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다.
fillStart = refTop;
}
}
return {
@@ -267,7 +264,9 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
},
reason,
cutLine: basinCut,
fillLine: basinFill,
// I형 관 하단 되메움선만 여기 담긴다(applyBasinPipeFill). ㄴ·ㄷ형 성토는
// 성토부선 체계로 옮겼다.
fillLine: null,
},
// 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정).
// 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영.
@@ -282,6 +281,8 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
shape === "I"
? iGroundIntersection()
: { offset: floorTopInner, elevation: anchor.elevation },
fillStart,
fillBottomElevation: anchor.elevation - floorThickness,
trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight,
trimElevation: anchor.elevation + wallHeight,
};
@@ -369,3 +370,19 @@ export function applyBasinPipeFill(
previousGap = gap;
}
}
/** 집수정 조립 결과 — 본체가 트림·접근선·관 접속에 그대로 꽂아 쓴다. */
export interface BasinAttachResult extends BasinBuildResult {
/** 성토 사면선 폴리라인(노견 → 수평 연장 → 벽 상단). 없으면 트림만. */
approach: { points: OffsetPoint[] } | null;
}
/** buildBasin + 접근선까지 한 번에 — 본체(Geom) 700줄 제한용 묶음. */
export function attachBasin(input: BasinBuildInput): BasinAttachResult {
const built = buildBasin(input);
const points = basinApproachPoints(input.edge, input.outward, input.adjust, {
offset: built.trimOffset,
elevation: built.trimElevation,
});
return { ...built, approach: points ? { points } : null };
}
@@ -499,3 +499,19 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
}
return result;
}
/** 성토부가 없는 쪽(구조물 미설치·매몰)의 빈 결과. */
export const NO_EXTRAS: OutletExtrasResult = {
walls: [],
segments: [],
appliedAdjusts: [],
addable: false,
};
/** start가 있을 때만 성토부·다단을 만든다 — 호출부의 삼항 중복을 없앤다. */
export function buildExtrasAt(
start: OffsetPoint | null,
rest: Omit<OutletExtrasInput, "start">,
): OutletExtrasResult {
return start ? buildOutletExtras({ ...rest, start }) : NO_EXTRAS;
}
@@ -48,13 +48,12 @@ export * from "./B06_Section_UI_Cross_Culvert_Types";
import {
applyBasinPipeFill,
basinApproachPoints,
attachBasin,
BASIN_INNER_WIDTH_M,
buildBasin,
inletChoiceAvailability,
resolveBasinChoice,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildOutletExtras, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampToFace,
@@ -77,7 +76,12 @@ export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
/** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */
revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] },
revetShift?: {
inlet?: WallAdjust;
outlet?: WallAdjust;
extras?: WallAdjust[];
basinExtras?: WallAdjust[];
},
inletStructure?: InletStructureChoice,
basinAdjustment?: BasinAdjust,
equalizeExtras?: boolean,
@@ -112,7 +116,7 @@ export function computeCulvertLayout(
const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge));
const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert };
// 유입측 집수정 판정(2026-08-22 재정의): 성토사면 ≤3m면 집수정 기본(절토 포함).
// 집수정 판정: 유입 성토사면 ≤3m면 기본(절토 포함).
const mode = section.design?.section_mode;
const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right";
const inletIsCut =
@@ -136,8 +140,7 @@ export function computeCulvertLayout(
: null;
const choice: InletStructureChoice = inletStructure ?? "auto";
const { reason: basinReason, shape: basinShape } = resolveBasinChoice(choice, ruleReason);
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치(2026-08-22 확정) — 바닥(=관
// 유입 invert)이 따라 올라 물매도 바뀐다. I형은 원지반 배관 유지(상세 Basin 참조).
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다.
if (basinReason && basinShape !== "I") {
inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM;
} else if (basinReason) {
@@ -147,9 +150,8 @@ export function computeCulvertLayout(
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
// 벽 제원(2026-08-22 4축 조작): 재질(기본 메쌓기)이 높이 한계(메2/찰3/콘5)를
// 정하고, 높이 = 사용자 설정 ?? 기본 2.5(한계로 절삭). 하한은 2.0 관경+여유고
// 중 큰 값 — 관이 벽 위로 삐져나오면 안 된다. 자동 자리 계산도 이 높이 기준.
// 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정하고, 높이 = 설정 ?? 기본 2.0.
// 하한 = max(2.0, 관경+여유고) — 관이 벽 위로 삐져나오면 안 된다.
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
@@ -165,7 +167,7 @@ export function computeCulvertLayout(
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한).
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
const inletOptions = inletChoiceAvailability({
designAt,
groundAt,
@@ -177,7 +179,7 @@ export function computeCulvertLayout(
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 조작값. 조정창이 이 값을 되받는다. */
/** 한계에 잘린 뒤의 실제 조작값 조정창이 되받는다. */
const appliedAdjust = {
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null },
@@ -187,16 +189,14 @@ export function computeCulvertLayout(
inlet: null,
outlet: null,
};
// 기슭막이 자동 자리 = **노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정)
// — 표준 높이 벽으로 물매 1:1.2가 딱 성립하는 가장 안쪽 자리. 관 m단위 올림 잔여가
// 벽을 바깥으로 밀면 그만큼만 노견이 조정된다. 못 찾으면 사면 끝으로 폴백.
// 자동 자리 = **노폭 연장 최소 지점**(1:1.2가 딱 성립하는 가장 안쪽). 없으면 사면 끝.
let inletAutoOffset: number | null = null;
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 4축 배치는 공용 풀이(placePipeWall — Solve, 700줄 제한): 좌우 = 노견 연장
// 4축 배치는 공용 풀이(placePipeWall — Solve): 좌우 = 노견 연장
// 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한.
// 유입을 내리면 유출도 따라 내려간다 — 유출이 못 받으면 유입도 못 내려간다(가드).
const placed = placeInletWall({
@@ -260,6 +260,9 @@ export function computeCulvertLayout(
let basin: BasinLayout | null = null;
/** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */
let basinPipeEnd: OffsetPoint | null = null;
/** 집수정 계류측 성토부 시작점·바닥(ㄴ·ㄷ형이 원지반 위로 나올 때만). */
let basinFillStart: OffsetPoint | null = null;
let basinFillBottom = 0;
/** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */
const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = {
inlet: null,
@@ -267,9 +270,7 @@ export function computeCulvertLayout(
};
let trimMin = Number.NEGATIVE_INFINITY;
let trimMax = Number.POSITIVE_INFINITY;
// 트림 경계에서 성토 경사선 끝**닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한
// 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다
// (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점).
// 트림 경계에서 사선 끝이 닿아야 할 표고(= 벽 이음선 상단점 — 2026-08-21 사용자 ②).
let trimMinElevation: number | null = null;
let trimMaxElevation: number | null = null;
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */
@@ -284,13 +285,10 @@ export function computeCulvertLayout(
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
// 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택
// revet(기슭막이 강제)를 존중하려면 여기서 다시 살리면 안 된다(2026-08-22).
forceBasinReason;
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
if (reason) {
// 집수정 구성은 분리 파일(_Cross_Culvert_Basin.ts — 700줄 제한)이 맡는다.
const built = buildBasin({
const built = attachBasin({
anchor,
outward,
shape: basinShape,
@@ -303,35 +301,27 @@ export function computeCulvertLayout(
});
basin = built.basin;
basinPipeEnd = built.pipeEnd;
basinFillStart = built.fillStart;
basinFillBottom = built.fillBottomElevation;
endFaces[spec.role] = built.endFace;
// 성토 사면선은 벽 상단 도로측 꼭짓점에서 끊고, 끝단 표고도 거기 맞춘다.
// 사면선은 벽 상단 도로측 꼭짓점에서 끊고 끝단 표고도 거기 맞춘다.
if (outward > 0) {
trimMax = built.trimOffset;
trimMaxElevation = built.trimElevation;
if (built.approach) trimMaxSlope = built.approach;
} else {
trimMin = built.trimOffset;
trimMinElevation = built.trimElevation;
}
const points = basinApproachPoints(inletInfo.edge, outward, basinAdjust, {
offset: built.trimOffset,
elevation: built.trimElevation,
});
if (points) {
if (outward > 0) trimMaxSlope = { points };
else trimMinSlope = { points };
if (built.approach) trimMinSlope = built.approach;
}
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
// (계류측)은 1:0.3 기운 평행사변형 띠. 높이는 4축 조작 제원(vertical)이 들고
// 들어온다 — 계산용 높이(근입 0.5 위 기준선~상단), 재질 한계는 pipeWallSpec 절삭.
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// **자리 기준 = 하단선 중점**(2026-08-21 사용자 확정). 배면 기준으로 잡으면 벽이
// 기울 때 체감 위치가 어긋난다. anchor.offset이 하단선 중점, anchor.elevation이
// 그 자리 원지반(= 관 invert)이다.
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
@@ -339,10 +329,7 @@ export function computeCulvertLayout(
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
// 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지.
const topFront = topJoint + outward * thickness;
// 하단선: **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 —
// 높이 기준을 "근입 0.5 위 기준선~상단"으로 확정하며 발자국 최저 지반 추적을
// 대체했다(2026-08-22 사용자). 지반이 더 낮으면 바닥이 뜨고(floatGapM 경고),
// 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장.
// 하단선 = **수평 기초**, 깊이 = 기준선 아래 근입 0.5m(전면 측 기준으로 수렴).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
@@ -484,9 +471,8 @@ export function computeCulvertLayout(
}
}
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③).
// 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다.
// 축·법선은 정수 맞춤이 벽(=관 끝)을 옮길 때마다 다시 유도한다.
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질
// 때마다 다시 유도한다(2026-08-20 사용자 ③).
let axis: OffsetPoint = { offset: 1, elevation: 0 };
let normal: OffsetPoint = { offset: 0, elevation: 1 };
let topAnchor: OffsetPoint = pipeStart;
@@ -529,16 +515,12 @@ export function computeCulvertLayout(
outlet: endCorners("outlet", outlet),
};
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출
// 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정).
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height);
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는
// 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은
// 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다.
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
@@ -595,10 +577,7 @@ export function computeCulvertLayout(
if (!outletWall) break;
continue;
}
// ② 더 옮기면 물매가 범위를 벗어나거나 사면이 5m를 넘는다 — 여기서 멈춘다.
// **폭으로 흡수하지 않는다**: 폭을 건드리면 좌·우 벽 단면이 서로 달라지고
// (2026-08-21 사용자 지적 — 유입을 밀었더니 유출 벽이 0.45→0.85로 부풀었다),
// 남는 몫은 관이 벽을 조금 벗어나는 것으로 두는 게 기존 확정 규칙이다.
// ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다).
break;
}
}
@@ -613,8 +592,8 @@ export function computeCulvertLayout(
: 0;
}
// I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 수평 되메움선(Basin 분리 — 700줄).
if (basin && !(basinAdjust.lateralM > 1e-6 && basinAdjust.slopeM <= 1e-6)) {
// I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 3도 되메움선(Basin 분리).
if (basin) {
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
}
// 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선.
@@ -628,19 +607,24 @@ export function computeCulvertLayout(
)
: null;
// 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로
// 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다.
const extras = outletWall
? buildOutletExtras({
start: pipeCorners.outlet.bottom,
startBottomElevation: outletWall.bottomBack.elevation,
outward: outletInfo.outward,
groundAt,
limitOffset: outletInfo.limit,
adjusts: (revetShift?.extras ?? []).map(adjustOf),
equalize: equalizeExtras === true,
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
limitOffset: outletInfo.limit,
adjusts: (revetShift?.extras ?? []).map(adjustOf),
equalize: equalizeExtras === true,
});
const basinExtras = buildExtrasAt(basinFillStart, {
startBottomElevation: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
equalize: false,
});
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -685,6 +669,8 @@ export function computeCulvertLayout(
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
},
outletFill: { segments: extras.segments, addable: extras.addable },
basinFill: { segments: basinExtras.segments, addable: basinExtras.addable },
basinExtras: basinExtras.walls,
inletFill,
extraWalls: extras.walls,
basin,
@@ -692,6 +678,7 @@ export function computeCulvertLayout(
inlet: appliedAdjust.inlet,
outlet: appliedAdjust.outlet,
extras: extras.appliedAdjusts,
basinExtras: basinExtras.appliedAdjusts,
},
designTrim:
walls.length || basin
@@ -174,6 +174,10 @@ export interface CulvertLayout {
};
/** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */
outletFill: { segments: OutletFillSegment[]; addable: boolean };
/** 유입 집수정(ㄴ·ㄷ형) 계류측 성토부선 — 유출측과 같은 체계(2026-08-22 사용자). */
basinFill: { segments: OutletFillSegment[]; addable: boolean };
/** 집수정 계류측 다단 기슭막이 — 조정창 키 `bextra{n}`. */
basinExtras: WallLayout[];
/** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지),
* 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */
inletFill: OutletFillSegment | null;
@@ -187,7 +191,13 @@ export interface CulvertLayout {
* 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면
* 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적).
*/
revetShift: { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] };
revetShift: {
inlet: WallAdjust;
outlet: WallAdjust;
extras: WallAdjust[];
/** 집수정 계류측 다단의 적용 조작값. */
basinExtras: WallAdjust[];
};
/**
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
* revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만),
@@ -41,16 +41,20 @@ export interface InletStructureControl {
}
/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
/** 다단 기슭막이가 붙는 쪽 — 유출 성토부 / 집수정 계류측. */
export type ExtraSide = "outlet" | "basin";
export interface ExtraWallControl {
countFor: (section: CrossSection) => number;
/** side: "outlet" = 유출 다단, "basin" = 집수정 계류측 다단(2026-08-22). */
countFor: (section: CrossSection, side?: ExtraSide) => number;
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
setCount: (chainageM: number, count: number) => void;
setCount: (chainageM: number, count: number, side?: ExtraSide) => void;
/** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */
equalize: (chainageM: number) => void;
/** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */
consumeEqualize: (section: CrossSection) => boolean;
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
syncCount: (chainageM: number, built: number) => void;
syncCount: (chainageM: number, built: number, side?: ExtraSide) => void;
}
import { showToast } from "@ui/ui_template_elements";
@@ -85,7 +89,10 @@ export function computeCardCulvert(
equalizeExtras,
);
if (!layout) return null;
if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) {
if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) {
extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin");
}
if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) {
// 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토
// 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다.
if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) {
@@ -94,7 +101,7 @@ export function computeCardCulvert(
"info",
);
}
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length);
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet");
}
if (revetOffset) {
const roles: Array<[RevetKey, WallAdjust]> = [
@@ -104,11 +111,17 @@ export function computeCardCulvert(
`extra${i}`,
applied,
]),
...layout.revetShift.basinExtras.map((applied, i): [RevetKey, WallAdjust] => [
`bextra${i}`,
applied,
]),
];
const wallOf = (role: RevetKey) =>
role.startsWith("extra")
? layout.extraWalls[Number(role.slice(5))]
: layout.walls.find((wall) => wall.role === role);
role.startsWith("bextra")
? layout.basinExtras[Number(role.slice(6))]
: role.startsWith("extra")
? layout.extraWalls[Number(role.slice(5))]
: layout.walls.find((wall) => wall.role === role);
for (const [role, applied] of roles) {
const requested = revetOffset.adjustFor(section, role);
// 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만).
@@ -154,14 +167,19 @@ function adjustsInput(
section: CrossSection,
revetOffset?: RevetOffsetControl,
extraWalls?: ExtraWallControl,
): { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] } | undefined {
):
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined {
if (!revetOffset) return undefined;
return {
inlet: revetOffset.adjustFor(section, "inlet"),
outlet: revetOffset.adjustFor(section, "outlet"),
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
extras: Array.from({ length: extraWalls?.countFor(section, "outlet") ?? 0 }, (_, i) =>
revetOffset.adjustFor(section, `extra${i}`),
),
basinExtras: Array.from({ length: extraWalls?.countFor(section, "basin") ?? 0 }, (_, i) =>
revetOffset.adjustFor(section, `bextra${i}`),
),
};
}
@@ -42,7 +42,12 @@ export interface StructurePanelDeps {
setStructure: (value: InletStructureChoice) => void;
basinAdjustFor: () => BasinAdjust;
updateBasin: (patch: Partial<BasinAdjust>) => void;
/** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */
nudgeBasinLateral: (screenDeltaM: number) => void;
resetBasin: () => void;
/** 집수정 계류측 다단 상태·단 수 지정(2026-08-22 — 유출측과 같은 체계). */
basinExtraState: () => { canAdd: boolean; count: number };
setBasinExtraCount: (count: number) => void;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
@@ -231,43 +236,89 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
});
structureParts.controls.append(select);
// 집수정 조작 = **9키(3×3)**(2026-08-22 사용자 확정) — 십자 축은 이동, 네 모서리는
// 크기, 가운데는 초기화. 기슭막이 십자와 축 규칙이 같아 따로 익힐 게 없다.
// ⇕+ 사면▲ ⇔+
// ◀ ↺ ▶
// ⇕- 사면▼ ⇔-
const basinRow = makeRow("집수정 크기·위치");
basinRow.controls.classList.add("b06-structure-panel__basin-buttons");
const basinButton = (label: string, titleText: string, patch: () => Partial<BasinAdjust>) =>
makeButton(label, titleText, () => deps.updateBasin(patch()));
const basinHeightUp = basinButton("높이+", "내부 높이 +0.1m", () => ({
innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M),
}));
const basinHeightDown = basinButton("높이-", "내부 높이 -0.1m", () => ({
innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M),
}));
const basinWidthDown = basinButton("폭-", "내부 폭 -0.1m", () => ({
innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M),
}));
const basinWidthUp = basinButton("폭+", "내부 폭 +0.1m", () => ({
innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M),
}));
const basinSlopeUp = basinButton("사면▲", "1:1.2 성토선을 따라 위로", () => ({
slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M),
}));
const basinSlopeDown = basinButton("사면▼", "1:1.2 성토선을 따라 아래로", () => ({
slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M,
}));
const basinIn = basinButton("노견◀", "노견측으로 0.1m", () => ({
lateralM: Math.max(0, deps.basinAdjustFor().lateralM - BASIN_STEP_M),
}));
const basinOut = basinButton("바깥▶", "노견 반대측으로 0.1m", () => ({
lateralM: deps.basinAdjustFor().lateralM + BASIN_STEP_M,
}));
const basinKey = (
label: string,
slot: string,
titleText: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, titleText, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
const basinPatch = (patch: () => Partial<BasinAdjust>) => () => deps.updateBasin(patch());
basinRow.controls.append(
basinHeightUp,
basinHeightDown,
basinWidthDown,
basinWidthUp,
basinSlopeUp,
basinSlopeDown,
basinIn,
basinOut,
basinKey(
"⇕+",
"nw",
"내부 높이 +0.1m",
basinPatch(() => ({
innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M),
})),
),
basinKey(
"▲",
"up",
"1:1.2 성토선을 따라 위로 0.1m",
basinPatch(() => ({ slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M) })),
),
basinKey(
"⇔+",
"ne",
"내부 폭 +0.1m",
basinPatch(() => ({
innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M),
})),
),
basinKey("◀", "left", L("B06_Cross_Revet_Left"), () => deps.nudgeBasinLateral(BASIN_STEP_M)),
basinKey("↺", "reset", L("B06_Cross_Revet_Reset"), () => deps.resetBasin()),
basinKey("▶", "right", L("B06_Cross_Revet_Right"), () => deps.nudgeBasinLateral(-BASIN_STEP_M)),
basinKey(
"⇕-",
"sw",
"내부 높이 -0.1m",
basinPatch(() => ({
innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M),
})),
),
basinKey(
"▼",
"down",
"1:1.2 성토선을 따라 아래로 0.1m",
basinPatch(() => ({ slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M })),
),
basinKey(
"⇔-",
"se",
"내부 폭 -0.1m",
basinPatch(() => ({
innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M),
})),
),
);
// 집수정 계류측 다단 기슭막이 단 수 — 유출측 행과 같은 형식([- 값 +]).
const basinExtraParts = makeRow("집수정 계류측 기슭막이(단)");
const basinExtraRow = basinExtraParts.row;
const basinCountValue = document.createElement("span");
basinCountValue.className = "b06-structure-panel__hval";
let basinCount = 0;
const clampBasinCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value)));
basinExtraParts.controls.append(
makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount - 1)),
),
basinCountValue,
makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount + 1)),
),
);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +),
@@ -299,6 +350,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
value,
structureRow,
basinRow.row,
basinExtraRow,
materialRow,
heightRow,
extraRow,
@@ -341,9 +393,19 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
moveRow.row.classList.toggle("is-hidden", !movable);
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
const basinExtra = deps.basinExtraState();
basinExtraRow.classList.toggle(
"is-hidden",
!(isBasin && (basinExtra.canAdd || basinExtra.count > 0)),
);
basinCount = basinExtra.count;
basinCountValue.textContent = `${basinExtra.count}`;
// I형은 내공(높이·폭) 개념이 없다 — 9키의 **네 모서리**만 숨긴다.
const isI = structure === "I";
for (const button of [basinHeightUp, basinHeightDown, basinWidthDown, basinWidthUp]) {
button.classList.toggle("is-hidden", isI);
for (const slot of ["nw", "ne", "sw", "se"]) {
basinRow.controls
.querySelector(`.b06-structure-panel__btn--${slot}`)
?.classList.toggle("is-hidden", isI);
}
materialRow.classList.toggle("is-hidden", !movable);
heightRow.classList.toggle("is-hidden", !movable);
+40 -6
View File
@@ -23,6 +23,7 @@ import {
sectionModeLabel,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { showToast } from "@ui/ui_template_elements";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
@@ -146,6 +147,8 @@ export function createCrossSectionCard(
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
let culvertExtraState = { canAdd: false, count: 0 };
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
let culvertBasinExtraState = { canAdd: false, count: 0 };
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/**
@@ -423,12 +426,16 @@ export function createCrossSectionCard(
canAdd: culvertLayout.outletFill.addable,
count: culvertLayout.extraWalls.length,
};
culvertBasinExtraState = {
canAdd: culvertLayout.basinFill.addable,
count: culvertLayout.basinExtras.length,
};
culvertWallSpecs = new Map();
for (const wall of [...culvertLayout.walls, ...culvertLayout.extraWalls]) {
const key =
wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role;
const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) =>
culvertWallSpecs.set(key, { height: wall.height, material: wall.material });
}
for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey);
culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
@@ -562,18 +569,45 @@ export function createCrossSectionCard(
...inletStructure.adjustFor(section),
...patch,
}),
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
nudgeBasinLateral: (screenDeltaM) => {
const current = inletStructure?.adjustFor(section);
if (!current) return;
const next = current.lateralM + screenDeltaM * outwardOf("inlet");
if (next < -1e-9) {
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
}
inletStructure?.updateAdjust(section.chainage_m, {
...current,
lateralM: Math.max(0, Math.round(next * 10) / 10),
});
},
resetBasin: () => inletStructure?.resetAdjust(section.chainage_m),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !culvertInletIsBasin,
optionsFor: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) {
if (
activeRevet?.startsWith("extra") &&
!activeRevet.startsWith("bextra") &&
Number(activeRevet.slice(5)) >= count
) {
activeRevet = "outlet";
revetOffset?.select(section.chainage_m, "outlet");
}
extraWalls?.setCount(section.chainage_m, count);
extraWalls?.setCount(section.chainage_m, count, "outlet");
},
setBasinExtraCount: (count) => {
if (activeRevet?.startsWith("bextra") && Number(activeRevet.slice(6)) >= count) {
activeRevet = "inlet";
revetOffset?.select(section.chainage_m, "inlet");
}
extraWalls?.setCount(section.chainage_m, count, "basin");
},
equalizeExtras: () => extraWalls?.equalize(section.chainage_m),
});
@@ -292,16 +292,22 @@ export function createStationControls(deps: StationControlDeps): StationControls
/** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */
const pendingEqualize = new Set<string>();
// 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다.
const extraKey = (chainageM: number, side: "outlet" | "basin"): string =>
side === "basin" ? `b${chainageM.toFixed(2)}` : chainageM.toFixed(2);
const extraPrefix = (side: "outlet" | "basin"): string => (side === "basin" ? "bextra" : "extra");
const extraWallControl: ExtraWallControl = {
countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0,
countFor: (section, side = "outlet") =>
extraCounts.get(extraKey(section.chainage_m, side)) ?? 0,
equalize: (chainageM) => {
if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미
pendingEqualize.add(chainageM.toFixed(2));
deps.refreshCard(chainageM);
},
consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)),
setCount: (chainageM, requested) => {
const key = chainageM.toFixed(2);
setCount: (chainageM, requested, side = "outlet") => {
const key = extraKey(chainageM, side);
const previous = extraCounts.get(key) ?? 0;
const count = Math.max(0, Math.min(9, Math.round(requested)));
if (count === previous) return;
@@ -309,20 +315,22 @@ export function createStationControls(deps: StationControlDeps): StationControls
else extraCounts.set(key, count);
// 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다.
for (let i = count; i < previous; i += 1) {
revetShifts.delete(revetKey(chainageM, `extra${i}`));
revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey));
}
persistRevetShifts();
persistExtraCounts();
deps.refreshCard(chainageM);
},
syncCount: (chainageM, built) => {
const key = chainageM.toFixed(2);
syncCount: (chainageM, built, side = "outlet") => {
const key = extraKey(chainageM, side);
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}`));
for (let i = built; i < count; i += 1) {
revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey));
}
persistRevetShifts();
persistExtraCounts();
},
@@ -107,12 +107,34 @@
gap: 2px;
}
.b06-structure-panel__basin-buttons {
/* 집수정 조작 = 9키(3×3, 2026-08-22 사용자) — 십자 축 이동 · 모서리 크기 · 중앙 초기화.
기슭막이 십자와 같은 폭·버튼 크기로 맞춰 행열이 어긋나지 않게 한다. */
.b06-structure-panel__controls.b06-structure-panel__basin-buttons {
display: grid;
grid-template-columns: repeat(4, max-content);
grid-template-areas:
"nw up ne"
"left reset right"
"sw down se";
justify-content: center;
gap: 2px;
}
.b06-structure-panel__btn--nw {
grid-area: nw;
}
.b06-structure-panel__btn--ne {
grid-area: ne;
}
.b06-structure-panel__btn--sw {
grid-area: sw;
}
.b06-structure-panel__btn--se {
grid-area: se;
}
.b06-structure-panel__basin-reset {
margin-left: auto;
}