feat(B06): 세월교 · 박스 흙막이 — 본체가 흙막이를 따라감 · 세월교 관 1 m 단위(80)

- 세월교 흙막이 쪽 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면 · 라벨 관 길이
- 세월교 관 길이 1 m 단위 — 기본 자리 올림만큼 흙막이를 바깥으로(유출 먼저) · 좌우 걸음 1 m
- 박스 흙막이를 바깥으로 밀면 상판 · 저판 · 내공이 배면까지 따라감
- 흙막이 ↺ 가 캐시 사본(design.revet_adjust)까지 비움 — 옮긴 자리가 되살아나던 것
- 시험: test_80_ford_pipe(새) · test_73_4_body_guard 유출 벽 1 m 맞춤 반영

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C99ToP6eDSTba2XZRUumb8
This commit is contained in:
2026-10-08 08:54:27 +09:00
co-authored by Claude Opus 5.5
parent 5dfe85c218
commit 9044087009
9 changed files with 305 additions and 14 deletions
+92 -5
View File
@@ -17,6 +17,7 @@ import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
CulvertDesignTrim,
CulvertLayout,
OffsetPoint,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
@@ -26,11 +27,13 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
type Role = "inlet" | "outlet";
/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) 표고 · 흙막이 기본 순수 높이(79-7 · 79b). */
/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) · 관 invert(세월교 바닥판 상면) 표고 ·
* 흙막이 기본 순수 높이(79-7 · 79b · 80). */
export interface BodyEnd {
offset: number;
top: number;
bottom: number;
invert: number;
height: number;
}
@@ -62,6 +65,7 @@ export function bodyGuardBase(
offset: side.offset,
top: side.topElevation,
bottom: side.bottomElevation,
invert: side.bottomElevation,
// 박스 높이 값 그대로(사용자 원문 2 m → 2 m · 79b)
height: layout.box.inner_height_m,
}
@@ -83,6 +87,7 @@ export function bodyGuardBase(
offset: side.pipeEnd.offset,
top: side.top.elevation,
bottom: side.slabTopElevation - layout.ford.slab_thickness_m,
invert: side.slabTopElevation,
height: side.top.elevation - (side.slabTopElevation - layout.ford.slab_thickness_m),
}
: null;
@@ -109,7 +114,7 @@ export function bodyGuardAxes(
lean: number;
thickness: number;
},
): { x: number; d: number } {
): BodyAxes {
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - end.top);
// 배면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
const probe = buildRevetWallGeometry({
@@ -123,7 +128,45 @@ export function bodyGuardAxes(
roadElevation: wall.edgeElevation,
});
const anchorOffset = end.offset - probe.bottomBack.offset;
return { x: (anchorOffset - wall.autoOffset) * wall.outward - d, d };
// 배면 → 관 invert 높이 전면 — 세월교 관이 흙막이 안으로 드는 길이(80)
const front =
probe.points[2].offset + wall.outward * probe.lean * (probe.topJoint.elevation - end.invert);
const through = (front - probe.bottomBack.offset) * wall.outward;
return {
x: (anchorOffset - wall.autoOffset) * wall.outward - d,
d,
through,
outward: wall.outward,
};
}
export type BodyAxes = { x: number; d: number; through: number; outward: number };
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
export function fordPipeFit(
base: BodyGuardBase | null,
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
axesOf: (role: Role) => BodyAxes | null,
): [BodyAxes | null, BodyAxes | null] {
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
if (!base || culvert.body !== "ford") return axes;
const roles = ["inlet", "outlet"] as const;
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
const tips = roles.map((role, i) => {
const end = base.ends[role];
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
return end && { offset: end.offset + reach, z: end.invert };
});
if (!tips[0] || !tips[1]) return axes;
// ponytail: 관 기울기는 작아 바깥 수평 이동으로 길이를 채움
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
const gap = Math.ceil(length - 1e-6) - length;
const at = guarded[1] ? 1 : 0;
const fit = guarded[at];
if (!fit || gap < 1e-6) return axes;
axes[at] = { ...fit, x: fit.x + gap };
return axes;
}
/** 벽이 선 쪽은 흙막이 트림 · 안 선 쪽은 본체 트림(73-4). 벽이 하나도 없으면 null. */
@@ -166,17 +209,61 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
): T | null {
if (!layout || !culvert?.culvert.body) return layout;
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
const mid = layout.label.at.offset;
const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] =>
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p));
if ("box" in layout && "topSlab" in layout) {
const sides = layout.sides.map((side) =>
guarded(side.role === "left" ? 1 : -1)
? { ...side, fillLine: [], fillSegments: [], cutLine: null }
: side,
);
return { ...layout, sides };
// 박스 본체가 흙막이 배면까지 따라감 — 흙막이를 바깥으로 민 만큼만(80)
let { topSlab, bottomSlab, barrel } = layout;
for (const side of layout.sides) {
const outward = side.role === "left" ? 1 : -1;
const wall = culvert.walls.find((w) => w.role !== "extra" && w.outward === outward);
const to = wall?.bottomBack.offset ?? side.offset;
if ((to - side.offset) * outward < 1e-6) continue;
[topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) =>
reach(p, outward, to),
);
}
const run =
Math.max(...topSlab.map((p) => p.offset)) - Math.min(...topSlab.map((p) => p.offset));
return { ...layout, sides, topSlab, bottomSlab, barrel, bodyLengthM: run };
}
const ford = layout as FordLayout;
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side,
);
return { ...ford, sides } as T;
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80)
let { pipe, slabBridge } = ford;
for (const side of ford.sides) {
const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward);
if (!wall) continue;
const front =
wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation);
pipe = {
outer: reach(pipe.outer, side.outward, front),
inner: reach(pipe.inner, side.outward, front),
};
slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset);
}
if (pipe === ford.pipe) return { ...ford, sides } as T;
const [right, left] = pipe.outer.slice(2);
const lengthM = Math.hypot(left.offset - right.offset, left.elevation - right.elevation);
const slabOffsets = [
...slabBridge,
...sides.flatMap((s) => s.parts.flatMap((p) => p.points)),
].map((p) => p.offset);
return {
...ford,
sides,
pipe,
slabBridge,
slabLengthM: Math.max(...slabOffsets) - Math.min(...slabOffsets),
label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` },
} as T;
}
@@ -26,6 +26,7 @@ import {
bodyGuardAxes,
bodyGuardBase,
bodyGuardHeight,
fordPipeFit,
mergeGuardTrim,
} from "./B06_Section_UI_Cross_Body_Guard";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -169,9 +170,10 @@ export function computeCulvertLayout(
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
const bodyAxesOf = (role: "inlet" | "outlet", info: typeof inletInfo) => {
const bodyAxesOf = (role: "inlet" | "outlet") => {
const end = body?.ends[role];
const spec = role === "inlet" ? inletWallSpec : outletWallSpec;
const [spec, info] =
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
if (!end) return null;
return bodyGuardAxes(end, {
autoOffset: slopeZeroAnchor(info.edge, info.outward, spec.height, spec.lean),
@@ -182,8 +184,7 @@ export function computeCulvertLayout(
thickness: REVET_THICKNESS_M,
});
};
const inletBody = bodyAxesOf("inlet", inletInfo);
const outletBody = bodyAxesOf("outlet", outletInfo);
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
+1
View File
@@ -647,6 +647,7 @@ export function createCrossSectionCard(
inletIsBasin: () => culvertInletIsBasin,
hiddenPipe: () => culvertHiddenPipe,
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford",
inletOptions: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
@@ -73,6 +73,8 @@ export interface StructurePanelContext {
hiddenPipe: () => boolean;
/** 세월교 · BOX암거 흙막이 세트인가(링크 카드는 소유 측점 세트 · 79b). */
bodyGuard?: () => boolean;
/** 세월교 흙막이 — 안이 관이라 관 보호공처럼 1 m 걸음(80). */
fordGuard?: () => boolean;
inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean };
extraState: () => { canAdd: boolean; count: number };
basinExtraState: () => { canAdd: boolean; count: number };
@@ -144,8 +146,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
// 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽·
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m.
fineMove: (key) => (key !== "inlet" && key !== "outlet") || ctx.hiddenPipe(),
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
fineMove: (key) =>
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
@@ -293,8 +293,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
refreshWithLinked(chainageM);
},
reset: (chainageM, role) => {
revetShifts.delete(revetKey(chainageM, role));
persistRevetShifts();
// 캐시 사본(design.revet_adjust)까지 비움 — 남기면 adjustFor 가 옮긴 자리로 되살림(80)
storeAdjust(revetKey(chainageM, role), { ...ZERO_ADJUST });
refreshWithLinked(chainageM);
},
};
@@ -0,0 +1,13 @@
# 상시 계획서
**남은 작업만 두는 유일한 계획서.** 목록이 비면 프로젝트 최종 완료.
## 80. 세월교 · 박스암거 흙막이 — 본체가 흙막이를 따라감
사용자 확정(10-8) — 추천 노선 현재 유지 · 횡단 보기 현재 유지 · 세월교 안은 배관(1 m 기준) · 흙막이면 관이 흙막이 안까지 · 좌우 이동에 본체가 따라감 · 박스암거 다시 확인.
- [x] 80-1 세월교 흙막이 쪽 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 흙막이 배면 · 라벨에 관 길이(L ○m)
- [x] 80-2 세월교 관 길이 1 m 단위 — 기본 자리에서 올림만큼 흙막이를 바깥으로(유출 먼저) · 세월교 흙막이 좌우 걸음 1 m
- [x] 80-3 박스 흙막이를 바깥으로 밀면 박스 본체(상판 · 저판 · 내공)가 흙막이 배면까지 따라감
- [x] 80-4 흙막이 ↺(자동 자리로)가 캐시 사본까지 비움 — 옮긴 자리가 되살아나던 것
- 검증완료 — test_80_ford_pipe 5 · 관련 40 통과 · typecheck · 화면 검증_랩탑메인 세월교 9+15 캐시(L6 → ▶ 두 번 L8 · 양쪽 흙막이 L7 · ↺ 복귀) · 검증_sub7 8+15 캐시 박스(▶ 1 m 본체 따라감) · 캐시 지움 · 저장 안 함
+120
View File
@@ -0,0 +1,120 @@
/* 80 세월교 흙막이 관 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14 유입 · 유출 흙막이) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_80_ford_pipe.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", f);
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const FORD = sections.find((s) => Math.abs(s.chainage_m - 173.087) < 0.6);
const clone = (v) => JSON.parse(JSON.stringify(v));
const r3 = (v) => Math.round(v * 1000) / 1000;
/** 관 길이 · 쪽마다 관 끝 ↔ 흙막이 전면(관 invert 높이) · 바닥판 끝 ↔ 흙막이 배면. */
function pipeFit(edit) {
const section = clone(FORD);
edit(section);
const list = sections.map((s) => (s === FORD ? section : s));
const { ford, culvert } = computeStoredLayouts(section, list);
const [right, left] = ford.pipe.outer.slice(2);
const ends = { 1: left.offset, [-1]: right.offset };
const slab = ford.slabBridge.map((p) => p.offset);
const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) };
return {
lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)),
label: ford.label.text,
sides: ford.sides.map((side) => {
const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward);
if (!wall) return { role: side.role, guard: false };
const front =
wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation);
return {
role: side.role,
guard: true,
pipeGap: r3(ends[side.outward] - front),
slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset),
};
}),
};
}
const move = (role, x) => (s) => {
s.design.revet_adjust = { ...(s.design.revet_adjust ?? {}), [role]: { x, d: null, h: null } };
};
const out = {
both: pipeFit(() => undefined),
inletPlus1: pipeFit(move("inlet", 1)),
outletPlus2: pipeFit(move("outlet", 2)),
inletOnly: pipeFit((s) => {
s.culvert.outlet = { role: "outlet", structure: "" };
}),
};
// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로
const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6);
function boxFit(edit) {
const section = clone(BOX);
edit(section);
const { box, culvert } = computeStoredLayouts(
section,
sections.map((s) => (s === BOX ? section : s)),
);
const top = box.topSlab.map((p) => p.offset);
return {
bodyLengthM: r3(box.bodyLengthM),
gaps: culvert.walls
.filter((w) => w.role !== "extra")
.map((w) => r3((w.outward > 0 ? Math.max(...top) : Math.min(...top)) - w.bottomBack.offset)),
};
}
out.boxAuto = boxFit(() => undefined);
out.boxInletPlus1 = boxFit(move("inlet", 1));
process.stdout.write(JSON.stringify(out));
+2 -1
View File
@@ -52,7 +52,8 @@ def test_박스를_늘리면_벽도_m_단위로_따라감(screen: dict) -> None:
def test_세월교_벽_배면이_관_끝(screen: dict) -> None:
walls = {w["role"]: w for w in screen["ford"]["walls"]}
assert walls["outlet"]["dOffset"] == pytest.approx(0, abs=1e-3)
# 유출은 관 1 m 맞춤만큼 바깥으로 물러남(80 · test_80_ford_pipe) — 1 m 안
assert abs(walls["outlet"]["dOffset"]) < 1
assert walls["outlet"]["dElevation"] == pytest.approx(0, abs=1e-3)
assert walls["inlet"]["dOffset"] == pytest.approx(0, abs=1e-3)
# 윗면 = 측벽 상단(상판 높이) — 노면 위로는 안 솟음(79-7)
+65
View File
@@ -0,0 +1,65 @@
"""80 세월교 · 박스 흙막이 — 세월교 안은 관이라 1 m 단위 · 관이 흙막이 안까지 · 흙막이를 옮기면 본체가 따라감.
화면 TS 를 그 자리에서 돌린 값(`helper_80_ford_pipe.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HELPER = Path(__file__).parent / "helper_80_ford_pipe.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def fit() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _whole(length: float) -> bool:
return abs(length - round(length)) < 1e-3
def test_기본_자리_관_길이는_1m_단위(fit: dict) -> None:
for case in ("both", "inletOnly"):
assert _whole(fit[case]["lengthM"]), fit[case]
assert fit[case]["label"].endswith(f"L{round(fit[case]['lengthM'])}m")
def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> None:
for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")):
for side in case["sides"]:
if side["guard"]:
assert side["pipeGap"] == pytest.approx(0, abs=1e-3)
assert side["slabGap"] == pytest.approx(0, abs=1e-3)
def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None:
base = fit["both"]["lengthM"]
assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3)
assert fit["outletPlus2"]["lengthM"] == pytest.approx(base + 2, abs=1e-3)
def test_흙막이_없는_쪽은_그대로(fit: dict) -> None:
assert [s["guard"] for s in fit["inletOnly"]["sides"]] == [True, False]
def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None:
assert fit["boxAuto"]["gaps"] == [0, 0]
assert fit["boxInletPlus1"]["gaps"] == [0, 0]
assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx(
fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3
)