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
+2
View File
@@ -244,6 +244,8 @@ export interface CulvertSet {
/** 기초 축 — "기초유" | "기초버림"(실무 정본 탭 제목). **비어 있으면 터파기를 안 그린다**
* — 고르기 전에는 근거가 없다(2026-09-09 사용자 확정 ④). */
foundation?: string | null;
/** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */
body?: "box" | "ford";
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
+48 -2
View File
@@ -315,6 +315,36 @@ def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
}
# 세월교 · 박스 유입 · 유출 구조 칸(73-4) — 「흙막이」면 배관 흙막이와 같은 세트를 함께 얹음.
_GUARD_KEYS = {"box": ("wing_in", "wing_out"), "ford": ("inlet_type", "outlet_type")}
def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str, Any] | None:
"""세월교 · 박스 흙막이 — 독립 기슭막이와 같은 세트 모양(관 숨김) · 자리 = 관 · 본체 끝(73-4).
같은 측점에 `culvert` 키로 함께 얹혀 배관 흙막이 길(그림 · 연동 · 경사 · 3D · 면적)을 그대로 탐.
흙막이를 안 고른 쪽 = 구조 빈 값(안 그림) · 둘 다 아니면 None. TS 짝 `guardSet`.
"""
sides: dict[str, Any] = {}
for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]):
guarded = str(values.get(key) or "").strip() == "흙막이"
sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""}
if not any(side["structure"] for side in sides.values()):
return None
return {
"type": "pipe",
"hidden_pipe": True,
"body": body,
"side": "양쪽",
"tiers": 1,
"foundation": None,
"pipe_kind": None,
"diameter_m": diameter_m,
"min_cover_m": 0.0,
**sides,
}
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
@@ -404,7 +434,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
("ford_height_m", "월류 높이", 0.0),
],
)
return {
spec = {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
@@ -423,6 +453,10 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
guard = _guard_set(values, "ford", spec["diameter_m"])
if guard:
spec["guard"] = guard
return spec
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
@@ -474,7 +508,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
slab = slab if slab and slab > 0 else FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
spec = {
"type": "box",
"inner_width_m": inner_width or 2.0,
"inner_height_m": inner_height or 2.0,
@@ -496,6 +530,15 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
guard = _guard_set(values, "box", box_body_height(spec))
if guard:
spec["guard"] = guard
return spec
def box_body_height(box: dict[str, Any]) -> float:
"""박스 본체 전체 높이(상판 + 내공 + 저판) — 흙막이 세트의 관경 자리(73-4)."""
return round(box["top_thickness_m"] + box["inner_height_m"] + box["slab_thickness_m"], 3)
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
@@ -578,6 +621,9 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
**spec,
"chainage_m": pipe_chainage,
}
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
if spec.get("guard"):
section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage}
attached += 1
break
return attached
@@ -188,8 +188,10 @@ function areaRowOf(
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
const pipeOwner =
!!section.culvert &&
!section.culvert.body &&
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
@@ -0,0 +1,150 @@
/* =============================================================================
* B06_Section_UI_Cross_Body_Guard.ts
* 세월교 · 박스 유입 · 유출 흙막이(73-4) — 관보호공 흙막이와 **같은 벽 · 같은 자리 규칙**.
*
* 세트는 배관 흙막이와 같은 `section.culvert`(관 숨김 · `body`)로 얹혀 그림 · 연동 · 경사 · 3D ·
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 전면이 관(세월교) · 본체(박스) 끝에 옴
* (배관 벽 전면이 관 끝인 것과 같음). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 본체가 이 측점에 없으면(연동 옆 측점) 배관 규칙 그대로 — 소유 측점 값을 절대값으로 넘겨 받음.
* ========================================================================== */
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { outletPipeEnd } from "./B06_Section_UI_Cross_Culvert_Tiers";
import type {
CulvertDesignTrim,
CulvertLayout,
OffsetPoint,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
type Role = "inlet" | "outlet";
/** 본체 끝(관 invert · 박스 저판 밑) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
export interface BodyGuardBase {
ends: Record<Role, OffsetPoint | null>;
trim: CulvertDesignTrim;
}
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
): BodyGuardBase | null {
const body = section.culvert?.body;
const adjust = section.design;
if (body === "box" && section.box) {
const layout = computeBoxLayout(section, samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.right ?? {}) },
});
if (!layout) return null;
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const endOf = (role: Role): OffsetPoint | null => {
const side = layout.sides.find(
(s) => (s.role === "left") === ((role === "inlet") === inletOnLeft),
);
return side ? { offset: side.offset, elevation: side.bottomElevation } : null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
if (body === "ford" && section.ford) {
const layout = computeFordLayout(section, samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
});
if (!layout) return null;
const endOf = (role: Role): OffsetPoint | null =>
layout.sides.find((s) => s.role === role)?.pipeEnd ?? null;
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
return null;
}
/**
* 벽 전면을 본체 끝에 맞춘 배관 벽 4축(x · d) — `placePipeWall` 역산.
* 바닥 기준(invert) = 본체 끝 표고 · 상단 = 노견 − d/1.2 · 자리 = 0점 + (x + d).
*/
export function bodyGuardAxes(
end: OffsetPoint,
wall: {
autoOffset: number;
outward: number;
height: number;
edgeElevation: number;
lean: number;
thickness: number;
},
): { x: number; d: number } {
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - wall.height - end.elevation);
// 전면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
const probe = buildRevetWallGeometry({
anchor: { offset: 0, elevation: end.elevation },
outward: wall.outward,
height: wall.height,
material: "dry",
role: "outlet",
thickness: wall.thickness,
lean: wall.lean,
roadElevation: wall.edgeElevation,
});
const anchorOffset = end.offset - outletPipeEnd(probe).offset;
return { x: (anchorOffset - wall.autoOffset) * wall.outward - d, d };
}
/** 벽이 선 쪽은 흙막이 트림 · 안 선 쪽은 본체 트림(73-4). 벽이 하나도 없으면 null. */
export function mergeGuardTrim(
guard: CulvertDesignTrim,
body: CulvertDesignTrim,
walls: readonly WallLayout[],
): CulvertDesignTrim | null {
if (!walls.length) return null;
const max = walls.some((w) => w.outward > 0) ? guard : body;
const min = walls.some((w) => w.outward < 0) ? guard : body;
return {
minOffset: min.minOffset,
maxOffset: max.maxOffset,
minElevation: min.minElevation,
maxElevation: max.maxElevation,
minSlope: min.minSlope,
maxSlope: max.maxSlope,
};
}
/** 소유 측점 적용값(x = 본체 기준) → 옆 측점이 받을 절대값 — 옆 측점엔 본체가 없음. */
export function bodyAbsolute(layout: CulvertLayout): CulvertLayout["revetShift"] {
const shift = layout.revetShift;
const bodyX = layout.bodyX;
if (!bodyX) return shift;
const plus = (adjust: WallAdjust, dx: number): WallAdjust => ({ ...adjust, x: adjust.x + dx });
return {
...shift,
inlet: plus(shift.inlet, bodyX.inlet),
outlet: plus(shift.outlet, bodyX.outlet),
};
}
/** 흙막이가 선 쪽의 본체 성토선을 걷음 — 그 쪽 성토선은 흙막이 벽이 냄(두 줄 방지). */
export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
layout: T | null,
culvert: CulvertLayout | null,
): T | null {
if (!layout || !culvert?.culvert.body) return layout;
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
if ("box" in layout && "topSlab" in layout) {
const sides = layout.sides.map((side) =>
guarded(side.role === "left" ? 1 : -1) ? { ...side, fillLine: [] } : side,
);
return { ...layout, sides };
}
const ford = layout as FordLayout;
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, fillSegments: [] } : side,
);
return { ...ford, sides } as T;
}
@@ -68,7 +68,8 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
// 세월교 · 박스 흙막이(73-4)는 흙막이를 고른 쪽(구조 있음)만 봄
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
@@ -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,
};
}
@@ -383,6 +383,8 @@ export function placePipeWall(input: {
adjust: WallAdjust;
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
defaultD: number;
/** x 하한 — 기본 0(안쪽 금지) · 세월교 · 박스 흙막이는 본체 끝이 0점이라 안 막음(73-4). */
minX?: number;
}): PipeWallPlacement {
const top0 = input.baseElevation0 + input.height;
const dLow = Math.max(
@@ -390,7 +392,7 @@ export function placePipeWall(input: {
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
);
const d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
const x = Math.max(input.adjust.x, 0);
const x = Math.max(input.adjust.x, input.minX ?? 0);
const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN;
return {
x,
@@ -220,6 +220,8 @@ export interface CulvertLayout {
* basinLUAllowed = 집수정 ㄴ·ㄷ형(구조물이 원지반으로 밀고 들어갈 때만).
*/
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
/** 세월교 · 박스 흙막이(73-4) — 본체 끝 자리의 배관 4축 x(적용 x 의 0점). 본체 없으면 없음. */
bodyX?: { inlet: number; outlet: number } | null;
}
/** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
@@ -4,6 +4,7 @@
* 분리(700줄 제한). 카드가 다시 그려질 때마다 한 번 불린다.
* ========================================================================== */
import { bodyAbsolute } from "./B06_Section_UI_Cross_Body_Guard";
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
@@ -426,14 +427,15 @@ export function computeCardCulvert(
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
const ownerApplied =
computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? ownerInput;
// 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4).
const ownerLayout = computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
);
const ownerApplied = ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput;
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
// 벽만 자기 값으로 선다(2026-08-30 사용자).
const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
+10 -2
View File
@@ -7,6 +7,7 @@
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
* ========================================================================== */
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
appendCrossAreaBands,
@@ -404,8 +405,15 @@ export function createCrossSectionCard(
revetLink,
);
// 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust());
const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust());
// 흙막이(73-4)가 선 쪽 본체 성토선은 걷음 — 그 쪽 성토선은 흙막이 벽이 냄.
const boxLayout = withoutGuardedFill(
computeBoxLayout(section, section.samples, bodies.boxAdjust()),
culvertLayout,
);
const fordLayout = withoutGuardedFill(
computeFordLayout(section, section.samples, bodies.fordAdjust()),
culvertLayout,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
// 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반
// 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다).
+45 -12
View File
@@ -6,7 +6,7 @@
* 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
* ========================================================================== */
import { pipeDiameterM } from "@util/common_util_culvert_sets";
import { guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
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";
@@ -17,7 +17,7 @@ import {
revetWallSpec,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { CrossSection, CulvertSet } from "./B06_Section_Api_Fetch";
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
type StationControls = ReturnType<typeof createStationControls>;
@@ -69,16 +69,14 @@ export function applyPipeOptionsToCache(
if (!owner) return;
// 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 —
// 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정).
if (owner.ford) {
applyFordFormOptions(ctx.ford, owner.chainage_m, patch);
return;
}
if (owner.ford) applyFordFormOptions(ctx.ford, owner.chainage_m, patch);
// BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다
// (2026-08-30 사용자: 세월교와 같은 문제).
if (owner.box) {
applyBoxFormOptions(ctx.box, owner.chainage_m, patch);
return;
}
else if (owner.box) applyBoxFormOptions(ctx.box, owner.chainage_m, patch);
// 세월교 · 박스 흙막이(73-4) — 폼 칸 전부로 세트를 다시 짓고 아래 배관 흙막이 길(높이 · 형태 ·
// 추가 흙막이)을 그대로 탐. 일부만 온 되얹기는 세트를 못 지어 건너뜀.
const body = owner.ford ? "ford" : owner.box ? "box" : null;
if (body && (!whole || !syncBodyGuard(owner, body, patch))) return;
const culvert = owner.culvert;
if (!culvert) return;
const num = (key: string): number | undefined => {
@@ -111,7 +109,7 @@ export function applyPipeOptionsToCache(
own(side, "revet_length_m", num(`${prefix}_revet_length_m`));
own(side, "revet_before_m", num(`${prefix}_revet_before_m`));
own(side, "revet_after_m", num(`${prefix}_revet_after_m`));
put(side, "structure", patch[`${prefix}_type`]);
if (!body) put(side, "structure", patch[`${prefix}_type`]); // 세월교 구조 칸 = 흙막이 고르기
}
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
@@ -144,7 +142,14 @@ export function applyPipeOptionsToCache(
});
}
}
if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole);
// 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4)
const soil = body ? soilCount(patch) : null;
if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0))
// 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴)
void import("../A00_Common/design/design_structure_pipe_calc").then((calc) =>
calc.setPipeExtraCount(owner.chainage_m, soil),
);
if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole);
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
@@ -165,6 +170,34 @@ export function applyPipeOptionsToCache(
}
}
/** 세월교 · 박스 흙막이 세트를 폼 칸 전부로 다시 지음(73-4) — 흙막이가 없으면 걷음. */
function syncBodyGuard(
owner: CrossSection,
body: "box" | "ford",
patch: Record<string, number | string>,
): boolean {
const box = owner.box;
const diameter = box
? box.top_thickness_m + box.inner_height_m + box.slab_thickness_m
: (owner.ford?.diameter_m ?? 1);
const guard = guardSet(patch, body, Number(diameter.toFixed(3)));
if (!guard) delete owner.culvert;
else
owner.culvert = {
...(guard as unknown as CulvertSet),
chainage_m: ((box ?? owner.ford) as { chainage_m?: number } | undefined)?.chainage_m,
};
return !!guard;
}
/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */
function soilCount(patch: Record<string, number | string>): number {
return Math.max(
0,
...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)),
);
}
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
const soilSent = new Map<string, { h: number | null; m: string | null }>();