feat(B06): 세월교 · 박스 흙막이 횡단 그림 — 관보호공 흙막이와 같은 벽 · 자리 · 연동 · 경사(73-4)

- 유입 · 유출 구조 「흙막이」면 배관 흙막이와 같은 세트(관 숨김 · body)를 같은 측점 culvert 자리에 함께 얹음(파이썬 _guard_set ↔ TS guardSet 짝 · 거울 시험)
- 자동 자리 = 벽 전면이 관(세월교) · 본체(박스) 끝 · 좌우 x 는 그 자리 기준(본체를 늘리면 벽도 따라감) · 상하 d 는 배관과 같음
- 연동 옆 측점 · 3D 연동 해제 자리는 본체 기준 x 를 절대값으로 넘겨 받음 · 3D 만료 해시에 흙막이 값 포함
- 설계선 트림 = 벽 선 쪽은 흙막이 · 안 선 쪽은 본체 · 벽 선 쪽 본체 성토선은 걷음(두 줄 방지) · 관 연장은 안 실음
- 좌측 폼 → 캐시: 세트 다시 짓기 · 높이 · 형태 · 추가 흙막이 상자 수 = 유출 다단 수
- [연동][경사] 회색 풀림(옆 측점에 벽이 서면 연동 · 구간 있으면 경사) — 배관과 같은 규칙
- 시험: test_73_4_body_guard(벽 전면 = 본체 끝 · 박스 +1 m 따라감 · 연동 옆 측점 벽 · 서버 Node 면적 = 화면) · 거울 시험 흙막이 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-07 08:23:48 +09:00
co-authored by Claude Opus 5.5
parent 5e3ad45248
commit ffbf772f3c
18 changed files with 619 additions and 52 deletions
@@ -22,10 +22,12 @@ import {
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
import { bodyGuardAxes, bodyGuardBase, mergeGuardTrim } from "./B06_Section_UI_Cross_Body_Guard";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinAdjust,
CulvertDesignTrim,
CulvertLayout,
EndFace,
InletStructureChoice,
@@ -152,6 +154,26 @@ export function computeCulvertLayout(
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 세월교 · 박스 흙막이(73-4) — 자동 자리 = 본체 끝(x 0점 · d 기본값). 본체 없으면 배관 규칙.
const body = culvert.body ? bodyGuardBase(section, groundSamples) : null;
const bodyAxesOf = (role: "inlet" | "outlet", info: typeof inletInfo) => {
const end = body?.ends[role];
const spec = role === "inlet" ? inletWallSpec : outletWallSpec;
if (!end) return null;
return bodyGuardAxes(end, {
autoOffset: slopeZeroAnchor(info.edge, info.outward, spec.height, spec.lean),
outward: info.outward,
height: spec.height,
edgeElevation: info.edge.elevation_m,
lean: spec.lean,
thickness: REVET_THICKNESS_M,
});
};
const inletBody = bodyAxesOf("inlet", inletInfo);
const outletBody = bodyAxesOf("outlet", outletInfo);
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;
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
const inletOptions = inletChoiceAvailability({
designAt,
@@ -214,10 +236,11 @@ export function computeCulvertLayout(
edgeElevation: inletInfo.edge.elevation_m,
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
adjust: adjInlet,
adjust: onBody(adjInlet, inletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
minX,
});
appliedAdjust.inlet.x = placed.x;
appliedAdjust.inlet.x = placed.x - (inletBody?.x ?? 0);
appliedAdjust.inlet.d = placed.d;
wallVertical.inlet = {
height: inletWallSpec.height,
@@ -321,7 +344,8 @@ export function computeCulvertLayout(
}
// 구조 · 형태를 안 고른 배관 벽 = 그리지 않음(54-39 · 조정창 형태가 있으면 그 값)
const pickedForm = (spec.role === "inlet" ? adjInlet : adjOutlet).m || spec.revet_form;
if (!culvert.hidden_pipe && (spec.structure !== "기슭막이" || !pickedForm)) return null;
if ((!culvert.hidden_pipe || culvert.body) && (spec.structure !== "기슭막이" || !pickedForm))
return null;
// 형상 = 공용 `buildRevetWallGeometry`(추가·사면안정 흙막이·옹벽과 한 벌). 높이는
// vertical(계산용)이 들고 온다 — 형태별 높이 한계 없음, 윗면은 노면에서 멈춤.
const height = vertical?.height ?? revetTargetHeight(diameter);
@@ -401,10 +425,11 @@ export function computeCulvertLayout(
edgeElevation: outletInfo.edge.elevation_m,
// 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
adjust: adjOutlet,
adjust: onBody(adjOutlet, outletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
minX,
});
appliedAdjust.outlet.x = placed.x;
appliedAdjust.outlet.x = placed.x - (outletBody?.x ?? 0);
appliedAdjust.outlet.d = placed.d;
wallVertical.outlet = {
height: outletWallSpec.height,
@@ -459,7 +484,7 @@ export function computeCulvertLayout(
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
@@ -608,6 +633,17 @@ export function computeCulvertLayout(
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const ownTrim: CulvertDesignTrim | null =
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null;
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -645,16 +681,8 @@ export function computeCulvertLayout(
extras: extras.appliedAdjusts,
basinExtras: basinExtras.appliedAdjusts,
},
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
bodyX:
inletBody || outletBody ? { inlet: inletBody?.x ?? 0, outlet: outletBody?.x ?? 0 } : null,
designTrim: body && ownTrim ? mergeGuardTrim(ownTrim, body.trim, walls) : ownTrim,
};
}