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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user