Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-06 16:45:02 +09:00
11 changed files with 149 additions and 44 deletions
@@ -137,9 +137,11 @@ export function revetWallSpec(
diameterM: number,
): { form: string; material: RevetMaterial; pureHeight: number; height: number } {
const form = revetFormOf(adjust.m, spec.revet_form);
const floor = hiddenPipe ? REVET_EMBED_DEPTH_M : pipeWallMinPureHeight(diameterM);
const preferred = hiddenPipe ? (spec.revet_height_m ?? floor) : floor;
const pureHeight = Math.max(adjust.h ?? preferred, floor);
// 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름).
const preferred = hiddenPipe
? (spec.revet_height_m ?? REVET_EMBED_DEPTH_M)
: pipeWallMinPureHeight(diameterM);
const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M);
return {
form,
material: materialFromForm(form),
@@ -161,8 +163,8 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
export function pipeWallMinPureHeight(diameterM: number): number {
return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;
}
/** 일반(추가) 기슭막이 높이 조작 하한(m) — 프로젝트 기본값 0.5m. */
export const EXTRA_WALL_MIN_HEIGHT_M = 0.5;
/** 벽 순수 높이 하한(m) — 0 이하는 그림이 안 섬. 관경 · 근입 하한 없음(54-51). */
export const WALL_MIN_PURE_HEIGHT_M = 0.1;
/** 일반(추가) 기슭막이 기본 높이(m). */
export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5;
@@ -14,7 +14,6 @@
import {
EXTRA_WALL_DEFAULT_HEIGHT_M,
PIPE_CONNECT_GRADE,
EXTRA_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
materialFromForm,
@@ -22,6 +21,7 @@ import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type {
OffsetPoint,
@@ -248,11 +248,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const material = materialFromForm(form);
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함) — 배관 기준벽과 같은 기준.
// 형태별 높이 한계 없음 — 사용자 입력 그대로(하한만 둠).
const height = Math.max(
adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M,
EXTRA_WALL_MIN_HEIGHT_M,
REVET_EMBED_DEPTH_M + 0.1,
);
const height = Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, WALL_MIN_PURE_HEIGHT_M);
/** 기준선(근입 위)~상단 — 도형·자리 계산은 이 값으로 한다. */
const exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
@@ -14,6 +14,7 @@ import {
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetTargetHeight,
@@ -324,7 +325,8 @@ export function computeCulvertLayout(
// 형상 = 공용 `buildRevetWallGeometry`(추가·사면안정 흙막이·옹벽과 한 벌). 높이는
// vertical(계산용)이 들고 온다 — 형태별 높이 한계 없음, 윗면은 노면에서 멈춤.
const height = vertical?.height ?? revetTargetHeight(diameter);
if (!(height > 0.05)) return null;
// 근입 포함 순수 높이가 서면 그림 — 관경 · 근입보다 낮아도 그대로(54-51).
if (!(height + REVET_EMBED_DEPTH_M > 0.05)) return null;
const side = outward > 0 ? edges.left : edges.right;
const wall = buildRevetWallGeometry({
anchor,
@@ -154,6 +154,9 @@ export interface CulvertLink {
* 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */
const extraLimitNotified = new Set<string>();
/** 벽마다 직전에 적용된 자리 — 자리 조작이 한계에 막혀 벽이 그대로일 때만 알리려고 둠. */
const lastApplied = new Map<string, WallAdjust>();
/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다.
* 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다
* (2026-09-07). 문구는 종전 그대로 쓴다. */
@@ -539,9 +542,17 @@ export function computeCardCulvert(
];
for (const [role, applied] of roles) {
const requested = revetOffset.adjustFor(section, role);
const memoKey = `${section.chainage_m.toFixed(2)}|${role}`;
const before = lastApplied.get(memoKey);
lastApplied.set(memoKey, applied);
// 높이 · 지형 때문에 자리가 따라 옮겨진 것은 알리지 않음 — 벽이 그대로일 때만(54-51).
const held = (axis: "x" | "d"): boolean =>
before?.[axis] != null &&
applied[axis] != null &&
Math.abs(Number(before[axis]) - Number(applied[axis])) < 0.05;
// 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만).
if (activeRevet === role) {
if (Math.abs(requested.x - applied.x) > 0.05) {
if (held("x") && Math.abs(requested.x - applied.x) > 0.05) {
showToast(
L(
requested.x < applied.x
@@ -551,15 +562,17 @@ export function computeCardCulvert(
"info",
);
}
if (requested.d != null && applied.d != null && Math.abs(requested.d - applied.d) > 0.05) {
if (
held("d") &&
requested.d != null &&
applied.d != null &&
Math.abs(requested.d - applied.d) > 0.05
) {
showToast(
L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"),
"info",
);
}
if (requested.h != null && applied.h != null && requested.h < applied.h - 0.05) {
showToast(L("B06_Cross_Height_Floor"), "info");
}
}
// d = null(기본 자리)은 null로 되돌려 담는다 — 적용 좌표를 그대로 저장하면
// 자동 자리가 "사용자 지정"으로 굳어 지형·높이 변경을 따라가지 못한다.
@@ -23,6 +23,7 @@ import {
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_THICKNESS_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
@@ -159,8 +160,10 @@ export function computeRevetmentLayout(
const spec = section.revetment;
const design = section.design;
if (!spec || !design) return null;
const requestedHeight = Number(adjust?.h ?? spec.height_m);
if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null;
const rawHeight = adjust?.h ?? spec.height_m;
// 빈 칸만 안 그림 — 0 이하는 아래 하한(0.1)에서 멈춤(54-51).
if (rawHeight == null || !Number.isFinite(Number(rawHeight))) return null;
const requestedHeight = Number(rawHeight);
// 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽
// (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설
@@ -181,7 +184,7 @@ export function computeRevetmentLayout(
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준).
// 형태별 높이 한계 없음 — 사용자 입력 그대로(하한만 둠).
const pureHeight = Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1);
const pureHeight = Math.max(requestedHeight, WALL_MIN_PURE_HEIGHT_M);
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
@@ -303,12 +303,12 @@ export function buildStructurePanel(
parts.row.classList.add("b06-structure-panel__struct--basin-size");
const readout = document.createElement("span");
readout.className = "b06-structure-panel__hval";
// 한계 1~2m 는 옛 9키 그대로.
// 한계 없음 — 그림이 안 서는 0 이하만 한 눈금에서 멈춤(54-51).
const step = (delta: number) =>
basinPatch(() => ({
[field]: Math.min(
2,
Math.max(1, Math.round((deps.basinAdjustFor()[field] + delta) * 10) / 10),
[field]: Math.max(
BASIN_STEP_M,
Math.round((deps.basinAdjustFor()[field] + delta) * 10) / 10,
),
}));
parts.controls.append(
@@ -7,7 +7,11 @@
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { REVET_EMBED_DEPTH_M, REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
import {
REVET_EMBED_DEPTH_M,
REVET_FORM_DEFAULT,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
@@ -95,7 +99,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
const current = ctx.adjustOf(key);
const base = current.h ?? ctx.heightOfWall(key);
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
h: Math.round((base + deltaM) * 10) / 10,
h: Math.max(WALL_MIN_PURE_HEIGHT_M, Math.round((base + deltaM) * 10) / 10),
});
},
formFor: (key) => ctx.formOfWall(key),
+2 -1
View File
@@ -14,6 +14,7 @@ import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -70,7 +71,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
const road = input.roadElevation;
const height =
road != null && anchor.elevation + input.height > road + 1e-9
? Math.max(road - anchor.elevation, 0)
? Math.max(road - anchor.elevation, WALL_MIN_PURE_HEIGHT_M - REVET_EMBED_DEPTH_M)
: input.height;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const lean = input.lean ?? REVET_LEAN_RATIO;
@@ -7,16 +7,14 @@
* ========================================================================== */
import { pipeDiameterM } from "@util/common_util_culvert_sets";
import { showToast } from "@ui/ui_template_elements";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
import {
applySpanPatch,
EXTRA_WALL_MIN_HEIGHT_M,
REVET_EMBED_DEPTH_M,
revetWallSpec,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
@@ -119,7 +117,7 @@ export function applyPipeOptionsToCache(
h: height ?? blank,
m: typeof form === "string" ? form : blank,
});
// 높이 하한(관경 기준 최소)에 걸린 실제 높이를 폼에 되돌린다 — 형태별 상한은 없음.
// 그림이 안 서는 값(0 이하)만 하한에서 멈춰 폼에 되돌린다 — 토스트 없음(54-51).
if (height === undefined) continue;
const applied = revetWallSpec(
role === "outlet" ? culvert.outlet : culvert.inlet,
@@ -131,11 +129,6 @@ export function applyPipeOptionsToCache(
ctx.overrideOptions(owner.chainage_m, {
[`${prefix}_revet_height_m`]: Number(applied.toFixed(1)),
});
showToast(
`${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 최소 ` +
`${applied.toFixed(1)}m로 적용됩니다.`,
"error",
);
}
}
if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole);
@@ -167,7 +160,7 @@ const soilSent = new Map<string, { h: number | null; m: string | null }>();
* 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값
* (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 —
* 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) ·
* 하한을 넘은 요청만 폼에 되돌림(형태별 상한 없음).
* 그림이 안 서는 값(0 이하)만 폼에 되돌림(형태별 상한 없음 · 토스트 없음).
*/
function applySoilTiers(
ctx: PipeOptionsContext,
@@ -192,11 +185,8 @@ function applySoilTiers(
m: typeof form === "string" && form ? form : blank,
};
if (want.h != null) {
const h = Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1);
if (Math.abs(h - want.h) > 0.05) {
clipped[`soil_add_${n}_height`] = Number(h.toFixed(1));
showToast(`흙막이 ${n} 높이는 최소 ${h.toFixed(1)}m로 적용됩니다.`, "error");
}
const h = Math.max(want.h, WALL_MIN_PURE_HEIGHT_M);
if (Math.abs(h - want.h) > 0.05) clipped[`soil_add_${n}_height`] = Number(h.toFixed(1));
want.h = Number(h.toFixed(1));
}
const sent = soilSent.get(key) ?? ctx.revetOffset.adjustFor(owner, role);
@@ -419,8 +419,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST };
const next = { ...current, ...patch };
basinAdjustments.set(key, {
innerWidthM: Math.min(2, Math.max(1, round1(next.innerWidthM))),
innerHeightM: Math.min(2, Math.max(1, round1(next.innerHeightM))),
innerWidthM: Math.max(0.1, round1(next.innerWidthM)),
innerHeightM: Math.max(0.1, round1(next.innerHeightM)),
lateralM: Math.max(0, clampMove(next.lateralM)),
slopeM: Math.max(0, clampMove(next.slopeM)),
});
@@ -0,0 +1,94 @@
"""높이 제약 풀기(54-51) — 관 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽 · 집수정 높이를 사용자 값 그대로.
남는 규칙: 그림이 안 서는 값(순수 높이 0 이하)만 0.1 에서 멈춤 · 토스트 없음.
서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
B06 = PROJECT_ROOT / "B06_Section"
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
_RUNNER = """
import { writeFileSync } from "node:fs";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const.js";
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js";
const spec = { revet_form: "찰쌓기", revet_height_m: 1.5 };
const pipe = (h) => revetWallSpec(spec, { h, m: null }, false, 0.8).pureHeight;
const own = (h) => revetWallSpec(spec, { h, m: null }, true, 0).pureHeight;
const extras = buildOutletExtras({
start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1,
groundAt: () => 80, limitOffset: 40,
adjusts: [{ x: 0, d: 0, h: 0.3, m: null }],
});
writeFileSync(process.argv[2], JSON.stringify({
pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeZero: pipe(0),
ownLow: own(0.3), ownNeg: own(-1),
extraCount: extras.walls.length,
extraPure: extras.walls.map((w) => w.topBack.elevation - w.bottomBack.elevation),
}));
"""
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_높이는_입력_그대로_0이하만_멈춤(tmp_path: Path) -> None:
out = tmp_path / "js"
for name in ("Culvert_Const", "Culvert_Extra"):
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(B06 / f"B06_Section_UI_Cross_{name}.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--skipLibCheck",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Const.js"))
(base / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True
)
got = json.loads(result.read_text(encoding="utf-8"))
assert got["pipeLow"] == pytest.approx(0.7), "관 흙막이가 관경 기준 최소로 되돌아감"
assert got["pipeAuto"] == pytest.approx(1.8), "빈 칸 기본값(관경 + 1.0) 틀림"
assert got["pipeZero"] == pytest.approx(0.1), "0 이하가 그림 하한에서 안 멈춤"
assert got["ownLow"] == pytest.approx(0.3), "흙막이 0.3 이 근입 기준 최소로 되돌아감"
assert got["ownNeg"] == pytest.approx(0.1), "음수 높이가 그림 하한에서 안 멈춤"
assert got["extraCount"] == 1, "낮은 추가 흙막이가 안 섬"
assert got["extraPure"][0] == pytest.approx(0.3), "추가 흙막이 0.3 이 되돌아감"
def test_높이_토스트_없음_서버_같은_판() -> None:
for name in ("UI_Cross_Culvert_Wire", "UI_Page_Pipe_Options"):
source = (B06 / f"B06_Section_{name}.ts").read_text(encoding="utf-8")
assert "B06_Cross_Height_Floor" not in source, f"{name} 에 최소 높이 토스트가 남음"
assert "높이는 최소" not in source, f"{name} 에 최소 높이 토스트가 남음"
panel = (B06 / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(encoding="utf-8")
assert "Math.min(\n 2," not in panel, "집수정 내공 2m 상한이 남음"
bundle = BUNDLE.read_text(encoding="utf-8")
assert "EXTRA_WALL_MIN_HEIGHT_M" not in bundle, "서버 번들이 옛 판 — npm run build:server-calc"
assert "WALL_MIN_PURE_HEIGHT_M" in bundle, "서버 번들이 옛 판 — npm run build:server-calc"