feat(B06): 흙막이 기본 자리 제일 위 · 연동 형상 색 구분 · 세월교 · 박스 흙막이 형상(79-5 · 79-6 · 79-7)

- 79-5 관 보호공 · 독립 기슭막이 · 옹벽 기본 자리 = 성토사면 제일 위(d 0) · 관은 m 단위 맞춤이 사면 아래로 미는 만큼만
- 79-6 연동 벽 = 옅은 청회색 점선 · 연동 벽을 고르면 주 횡단도 옅은 빨강 테두리 · 연동 측점 카드만 고르면 좌측 패널은 일반 측점 · 세월교 측벽 판정면 = ㄴ 을 감싸는 사각형
- 79-7 세월교 · 박스 흙막이 = 배면이 본체 끝에 붙고 윗면 = 본체 윗면 · 높이 빈 칸 = 본체 끝 높이(박스 2.0 → 2.5 = 상판 + 내공 + 저판) · 세월교 흙막이 쪽 ㄴ형 측벽 걷음 · 끝 절토 = 수평공간(새 칸 · 기본 1 m) 뒤 절토 경사 · 면적에 듦
- 79-7 박스 본체 늘 수평(좌 · 우 표고 한 값) · 박스 날개벽 끝 절 · 성토선(세월교와 같은 체계)
- 수평공간 칸 = 파이썬 _guard_set ↔ TS guardSet 짝 · 구조물정의 이름 표 · B05 저장 칸 목록
- 시험: test_79_guard_shape(새) · test_73_4_body_guard · test_65_ford_defaults · test_user_values_survive 맞춤

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-08 00:16:05 +09:00
co-authored by Claude Opus 5.5
parent 11148c60fb
commit e9539fcb12
34 changed files with 667 additions and 113 deletions
+2 -2
View File
@@ -15,7 +15,7 @@
* ========================================================================== */
import type { Instance, SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec";
import { CHUTE_ADD, guardItems, revetKeys, SOIL_ADD } from "./design_structure_guard";
import { bodyGuardItems, CHUTE_ADD, SOIL_ADD } from "./design_structure_guard";
export const BOX_CUSTOM = "사용자 입력";
/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */
@@ -50,7 +50,7 @@ const guardContainer = (
{ value: "흙막이", label: "흙막이" },
],
},
...guardItems({ 흙막이: revetKeys(id) }, undefined, true),
...bodyGuardItems(id),
],
});
+2 -2
View File
@@ -12,7 +12,7 @@
* ========================================================================== */
import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec";
import { CHUTE_ADD, guardItems, revetKeys, SOIL_ADD } from "./design_structure_guard";
import { bodyGuardItems, CHUTE_ADD, SOIL_ADD } from "./design_structure_guard";
/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */
const guard = (side: "in" | "out"): SpecChoice[] => [
@@ -35,7 +35,7 @@ const guardContainer = (id: "inlet" | "outlet", title: string, link: string): Sp
key: `${id}_type`,
choices: guard(id === "inlet" ? "in" : "out"),
},
...guardItems({ 흙막이: revetKeys(id) }, undefined, true),
...bodyGuardItems(id),
],
});
@@ -82,6 +82,23 @@ export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => (
after: `${side}_revet_after_m`,
});
/** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 본체 끝 높이(횡단도가 셈) ·
* 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */
export function bodyGuardItems(side: "inlet" | "outlet"): SpecItem[] {
const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) =>
item.id === "height" ? { ...item, default: undefined, hint: "본체 끝 높이(자동)" } : item,
);
const berm: SpecItem = {
id: "berm",
label: "수평공간 (m)",
kind: "number",
step: 0.1,
default: "1",
by: { item: "structure", map: { 흙막이: { key: `${side}_revet_berm_m` } } },
};
return [...items, berm];
}
/** 추가 흙막이(선택 · 여러 개) — 사면안정 흙막이(표 09 · 54-26)와 같은 칸 · 구조 고르기 없음(54-30). */
export const SOIL_ADD: SpecContainer = {
id: "soil_add",
+2 -2
View File
@@ -381,7 +381,7 @@ export async function mountCrossView(
(chainageM) => shared.bindings[shared.view()]?.structures?.onPipeSelect?.(chainageM),
);
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
// 연동으로 옆에서 넘어온 카드는 벽을 찍었을 때만 **소유 측점** 시설 · 카드만 고르면 일반 측점(79-6).
sectionView.setStationSelectListener((stationId) => {
const target = stationId
? sectionDetail?.cross_sections.find((section) => section.station_id === stationId)
@@ -391,7 +391,7 @@ export async function mountCrossView(
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
return;
}
const owner = stationControls.structureSpan.ownerOf(target);
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
// 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다.
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
@@ -42,6 +42,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"wing_out",
...revetKeys("inlet"),
...revetKeys("outlet"),
"inlet_revet_berm_m", // 흙막이 절토부 앞 수평공간(79-7)
"outlet_revet_berm_m",
],
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 ·
// 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
@@ -58,6 +60,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"wing_out",
...revetKeys("inlet"),
...revetKeys("outlet"),
"inlet_revet_berm_m",
"outlet_revet_berm_m",
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
+2
View File
@@ -202,6 +202,8 @@ export interface CulvertSideSpec {
/** 기준측점 전/후 종방향 몫(m) — 3D 예상형상 배치(2026-08-23). 없으면 길이 절반씩. */
revet_before_m?: number | null;
revet_after_m?: number | null;
/** 세월교 · 박스 흙막이 절토부 앞 수평공간(m · 79-7) — 흙막이 컨테이너 칸 · 없으면 기본값. */
revet_berm_m?: number | null;
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 기본 2m. */
basin_length_m?: number | null;
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
@@ -329,6 +329,9 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
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 guarded:
# 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값
sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None)
if not any(side["structure"] for side in sides.values()):
return None
return {
+16 -8
View File
@@ -12,6 +12,7 @@
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -107,14 +108,21 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C
extraWalls,
link,
);
const box = computeBoxLayout(section, section.samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
});
const ford = computeFordLayout(section, section.samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
});
// 흙막이가 선 쪽 본체 선 · 세월교 ㄴ형 측벽은 걷음 — B06 카드와 같은 모양(79-7).
const box = withoutGuardedFill(
computeBoxLayout(section, section.samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
}),
culvert,
);
const ford = withoutGuardedFill(
computeFordLayout(section, section.samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
}),
culvert,
);
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
const own =
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
+43 -20
View File
@@ -3,8 +3,9 @@
* 세월교 · 박스 유입 · 유출 흙막이(73-4) — 관보호공 흙막이와 **같은 벽 · 같은 자리 규칙**.
*
* 세트는 배관 흙막이와 같은 `section.culvert`(관 숨김 · `body`)로 얹혀 그림 · 연동 · 경사 · 3D ·
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 전면이 관(세월교) · 본체(박스) 끝에 옴
* (배관 벽 전면이 관 끝인 것과 같음). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 **배면(수직)이 본체 끝**에 붙어 벽 몸이 본체
* 바깥에 섬 · 벽 윗면 = 본체 윗면(세월교 상판 · 박스 상판) · 높이 기본 = 본체 끝 높이(79-7 · 그림
* `tmp/79_세월교_기본위치.png`). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 본체가 이 측점에 없으면(연동 옆 측점) 배관 규칙 그대로 — 소유 측점 값을 절대값으로 넘겨 받음.
* ========================================================================== */
@@ -12,11 +13,9 @@ 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";
@@ -26,9 +25,16 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
type Role = "inlet" | "outlet";
/** 본체 끝(관 invert · 박스 저판 밑) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) 표고(79-7). */
export interface BodyEnd {
offset: number;
top: number;
bottom: number;
}
/** 본체 끝(세월교 관 끝 · 박스 끝) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
export interface BodyGuardBase {
ends: Record<Role, OffsetPoint | null>;
ends: Record<Role, BodyEnd | null>;
trim: CulvertDesignTrim;
}
@@ -45,11 +51,13 @@ export function bodyGuardBase(
});
if (!layout) return null;
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const endOf = (role: Role): OffsetPoint | null => {
const endOf = (role: Role): BodyEnd | null => {
const side = layout.sides.find(
(s) => (s.role === "left") === ((role === "inlet") === inletOnLeft),
);
return side ? { offset: side.offset, elevation: side.bottomElevation } : null;
return side
? { offset: side.offset, top: side.topElevation, bottom: side.bottomElevation }
: null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
@@ -59,19 +67,31 @@ export function bodyGuardBase(
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;
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
const endOf = (role: Role): BodyEnd | null => {
const side = layout.sides.find((s) => s.role === role);
return side
? {
offset: side.pipeEnd.offset,
top: side.top.elevation,
bottom: side.slabTopElevation - layout.ford.slab_thickness_m,
}
: null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
return null;
}
/** 흙막이 기본 순수 높이(근입 포함) = 본체 끝 높이(윗면 ~ 바닥 · 79-7). */
export const bodyGuardHeight = (end: BodyEnd): number => end.top - end.bottom;
/**
* 벽 전면을 본체 끝에 맞춘 배관 벽 4축(x · d) — `placePipeWall` 역산.
* 바닥 기준(invert) = 본체 끝 표고 · 상단 = 노견 − d/1.2 · 자리 = 0점 + (x + d).
* 벽 배면을 본체 끝에 붙인 배관 벽 4축(x · d) — `placePipeWall` 역산.
* 상단 = 본체 윗면 = 노견 − d/1.2 · 바닥 기준(invert) = 상단 − 높이 · 자리 = 0점 + (x + d).
*/
export function bodyGuardAxes(
end: OffsetPoint,
end: BodyEnd,
wall: {
autoOffset: number;
outward: number;
@@ -81,10 +101,10 @@ export function bodyGuardAxes(
thickness: number;
},
): { x: number; d: number } {
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - wall.height - end.elevation);
// 전면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - end.top);
// 배면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
const probe = buildRevetWallGeometry({
anchor: { offset: 0, elevation: end.elevation },
anchor: { offset: 0, elevation: end.top - wall.height },
outward: wall.outward,
height: wall.height,
material: "dry",
@@ -93,7 +113,7 @@ export function bodyGuardAxes(
lean: wall.lean,
roadElevation: wall.edgeElevation,
});
const anchorOffset = end.offset - outletPipeEnd(probe).offset;
const anchorOffset = end.offset - probe.bottomBack.offset;
return { x: (anchorOffset - wall.autoOffset) * wall.outward - d, d };
}
@@ -129,7 +149,8 @@ export function bodyAbsolute(layout: CulvertLayout): CulvertLayout["revetShift"]
};
}
/** 흙막이가 선 쪽의 본체 성토선을 걷음 — 그 쪽 성토선은 흙막이 벽이 냄(두 줄 방지). */
/** 흙막이가 선 쪽의 본체 성토 · 절토선을 걷음 — 그 쪽 선은 흙막이 벽이 냄(두 줄 방지).
* 세월교는 그 쪽 ㄴ형 측벽도 걷음 — 흙막이가 측벽 자리를 대신함(79-7). */
export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
layout: T | null,
culvert: CulvertLayout | null,
@@ -138,13 +159,15 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
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,
guarded(side.role === "left" ? 1 : -1)
? { ...side, fillLine: [], fillSegments: [], cutLine: null }
: side,
);
return { ...layout, sides };
}
const ford = layout as FordLayout;
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, fillSegments: [] } : side,
guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side,
);
return { ...ford, sides } as T;
}
+23
View File
@@ -126,6 +126,29 @@ export function appendBoxOverlay(
polyline.append(title);
layer.append(polyline);
}
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
for (const side of layout.sides) {
const mark = side.role === "left" ? "좌" : "우";
const lines = side.fillSegments.map((segment) => ({
points: segment.points,
text: `${mark}측 끝 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m`,
}));
if (side.cutLine)
lines.push({
points: [side.cutLine.from, side.cutLine.to],
text: `${mark}측 끝 절토선(계획선)`,
});
for (const line of lines) {
if (line.points.length < 2) continue;
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", line.points.map((point) => toXY(point).join(",")).join(" "));
polyline.setAttribute("class", "b06-chart__design-cross");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = line.text;
polyline.append(title);
layer.append(polyline);
}
}
const label = document.createElementNS(SVG_NS, "text");
const [labelX, labelY] = toXY(layout.label.at);
+46 -6
View File
@@ -5,18 +5,25 @@
*
* 구성(2026-08-25 사용자 확정):
* · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다.
* · 상판 윗면 = 노면 − 복토 0.5m(별표2 교차 참조)가 기본이고, 조정창에서 **좌·우 끝
* 표고를 따로** 올리고 내린다 — 값이 다르면 구체가 기울어진다(물매).
* · 상판 윗면 = 노면 − 복토 0.5m(별표2 교차 참조)가 기본이고, 조정창에서 표고를 올리고
* 내린다 — 본체는 **늘 수평**(79-7 · 좌 · 우 값이 달라도 평균 하나로 섬).
* · **성토선은 그쪽 구체 최상단 모서리에서 끝난다.** 물매 1:1.2 고정이라 구체가 길수록
* 노견이 수평으로 늘어난다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙).
* · **날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평**이고 구체 저판만 기울기를
* 따라간다. 에이프런 길이 = 날개벽 투영(길이×cos각).
* · 내공(유로)은 비워 둔다 — 면을 칠하지 않는다.
* · 끝(에이프런 바깥 윗면)이 원지반에 묻히면 절토선, 위로 나오면 성토부선(세월교와 같은 체계 · 79-7).
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
* ========================================================================== */
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
CulvertDesignTrim,
OffsetPoint,
OutletFillSegment,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
@@ -24,7 +31,7 @@ import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_C
export interface BoxSideAdjust {
/** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */
lengthM: number;
/** 그쪽 끝의 구체 표고 조정(m, + = 위). 좌·우가 다르면 구체가 기운다. */
/** 구체 표고 조정(m, + = 위) — 본체는 늘 수평이라 좌 · 우 평균 하나로 섬(79-7). */
riseM: number;
}
@@ -54,6 +61,10 @@ export interface BoxSideLayout {
apron: OffsetPoint[];
/** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */
fillLine: OffsetPoint[];
/** 끝 바깥 성토부선 — 끝이 원지반 위로 나올 때(세월교와 같은 체계 · 79-7). */
fillSegments: OutletFillSegment[];
/** 끝이 원지반에 묻힐 때의 절토선(계획선). */
cutLine: BasinLayout["cutLine"];
/** 적용된 조작값(한계에 걸린 뒤). */
adjust: BoxSideAdjust;
}
@@ -119,13 +130,17 @@ export function computeBoxLayout(
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m;
const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m;
// 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬.
const riseM = (adjust.left.riseM + adjust.right.riseM) / 2;
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
const sideOf = (role: BoxSideRole): BoxSideLayout => {
const outward = role === "left" ? 1 : -1;
const edgeOffset = role === "left" ? leftEdge : rightEdge;
const wanted = role === "left" ? adjust.left : adjust.right;
const lengthM = Math.max(wanted.lengthM, 0);
const topElevation = baseTop + wanted.riseM;
const topElevation = baseTop + riseM;
// 성토선이 구체 최상단을 지나려면 (노면 − 상단) × 물매만큼은 노견 밖으로 나가야 한다.
const reach = Math.max((roadTopAt(edgeOffset) - topElevation) * FILL_SLOPE_RATIO_MIN, 0);
const offset = edgeOffset + outward * (reach + lengthM);
@@ -148,6 +163,29 @@ export function computeBoxLayout(
const fillLine: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
if ((start - edgeOffset) * outward > 0.01) fillLine.push({ offset: start, elevation: roadZ });
fillLine.push({ offset, elevation: topElevation });
// 끝 바깥 절 · 성토선 — 세월교 측벽 바닥판 끝과 같은 규칙(`buildBasin` · 79-7).
const end: OffsetPoint = { offset: outer, elevation: bottomElevation + box.slab_thickness_m };
const limitOffset = outward > 0 ? Math.max(...sampleOffsets) : Math.min(...sampleOffsets);
let cutLine: BasinLayout["cutLine"] = null;
let fillSegments: OutletFillSegment[] = [];
if (groundAt(end.offset) > end.elevation + 0.05) {
for (let h = 0.05; h <= 20; h += 0.05) {
const probe = end.offset + outward * cutSlopeRatio * h;
if (groundAt(probe) <= end.elevation + h) {
cutLine = { from: end, to: { offset: probe, elevation: end.elevation + h } };
break;
}
}
} else if (groundAt(end.offset) < end.elevation - 0.05) {
fillSegments = buildExtrasAt(end, {
startBottomElevation: bottomElevation,
outward,
groundAt,
limitOffset,
adjusts: [],
equalize: false,
}).segments.filter((segment) => segment.kind !== "cut");
}
return {
role,
offset,
@@ -155,7 +193,9 @@ export function computeBoxLayout(
bottomElevation,
apron,
fillLine,
adjust: { lengthM, riseM: wanted.riseM },
fillSegments,
cutLine,
adjust: { lengthM, riseM },
};
};
@@ -67,8 +67,8 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
// 세월교 · 박스 흙막이(73-4)는 흙막이를 고른 쪽(구조 있음)만 봄
// 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함
const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
+14 -4
View File
@@ -76,6 +76,8 @@ export function appendCulvertOverlay(
/** 관을 그리지 않는다 — 관은 한 측점에만 있고, 연장이 넘어온 옆 측점 카드에는
* 기슭막이만 링크로 보인다(2026-08-24 사용자). */
hidePipe?: boolean,
/** 옆 측점에서 연동으로 넘어온 벽 — 주 형상과 색을 갈라 그림(79-6). */
linked?: boolean,
): RevetHighlightSetter {
const { culvert, pipe, pipeCorners } = layout;
const diameter = culvert.diameter_m;
@@ -103,7 +105,12 @@ export function appendCulvertOverlay(
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
// 건드리지 않는다(그림만 — 면적은 전면 개편 대상).
if (segment.kind === "cut") continue;
// 세월교 · 박스 흙막이는 수평공간 뒤 절토선을 그림(79-7 · 면적에도 듦).
if (segment.kind === "cut" && !culvert.body) continue;
if (segment.kind === "cut") {
planLine(segment.points, `유출부 절토선 — 수평공간 뒤 절토 경사로 원지반까지`);
continue;
}
planLine(
segment.points,
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
@@ -126,9 +133,11 @@ export function appendCulvertOverlay(
for (const segment of layout.basinFill.segments) {
planLine(
segment.points,
segment.kind === "cut"
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
segment.kind === "cut" && culvert.body
? `${basinFillName} 절토선 — 수평공간 뒤 절토 경사로 원지반까지`
: segment.kind === "cut"
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
@@ -191,6 +200,7 @@ export function appendCulvertOverlay(
: ""),
);
if (onSelectRevet) revetShape.classList.add("is-selectable");
if (linked) revetShape.classList.add("is-linked");
revetShapes.set(wallKey, revetShape);
layer.append(revetShape);
// 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이
@@ -135,11 +135,13 @@ export function revetWallSpec(
adjust: { h?: number | null; m?: string | null },
hiddenPipe: boolean,
diameterM: number,
/** 높이 빈 칸의 기본 순수 높이 — 세월교 · 박스 흙막이 = 본체 끝 높이(79-7). */
autoHeightM?: number | null,
): { form: string; material: RevetMaterial; pureHeight: number; height: number } {
const form = revetFormOf(adjust.m, spec.revet_form);
// 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름).
const preferred = hiddenPipe
? (spec.revet_height_m ?? REVET_EMBED_DEPTH_M)
? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M)
: pipeWallMinPureHeight(diameterM);
const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M);
return {
@@ -182,13 +184,16 @@ export const FILL_SLOPE_RATIO_MAX = 2.0;
export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
/**
* 배관 기슭막이 기본 자리 — 노견에서 **성토사면 경사길이 4m 지점**(2026-08-23 사용자
* 확정). 상하(d) 조작의 0점은 성토선 길이 0 지점(벽 상단 = 노견)이고, d는 그 점에서
* 1:1.2 성토선을 타고 내려간 수평 성분(m)이다. 기본 자리가 안 되는 지형(매몰·역경사·
* 토피)은 배치 풀이의 기존 한계 논리가 안쪽으로 되돌린다.
* 흙막이 · 옹벽 · 관 보호공 기본 자리 = 성토사면을 따라 **가능한 범위 안 제일 위**(79-5) —
* 성토선 길이 0 지점(벽 상단 = 노견). 상하(d) 조작의 0점도 그 점이고, d는 1:1.2 성토선을
* 타고 내려간 수평 성분(m)이다. 관 보호공은 배치 풀이의 한계(토피 · 역경사)와 관 m 단위
* 맞춤(유출 벽을 사면 아래로 밂)이 그 위에서 자리를 정한다.
*/
export const PIPE_WALL_DEFAULT_SLOPE_M = 4.0;
/** 기본 자리(경사길이 4m)의 수평 성분(m) — d 기본값. */
export const PIPE_WALL_DEFAULT_SLOPE_M = 0;
/** 기본 자리의 수평 성분(m) — d 기본값. */
/** 세월교 · 박스 흙막이 절토부 앞 수평공간 기본값(m · 79-7) — 흙막이 컨테이너 칸이 비면.
* 관 유입 접속선의 「1m 공간 확보」(2026-08-23 사용자)와 같은 값. */
export const GUARD_CUT_BERM_M = 1.0;
export const PIPE_WALL_DEFAULT_RUN_M =
(PIPE_WALL_DEFAULT_SLOPE_M * FILL_SLOPE_RATIO_MIN) / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
@@ -48,6 +48,52 @@ export interface OutletExtrasInput {
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
/** 끝이 원지반에 묻힐 때 수평공간 뒤 절토 경사(1:n)로 지반까지(세월교 · 박스 흙막이 · 79-7).
* 없으면 종전 3도 선. */
cutBerm?: { lengthM: number; slopeRatio: number };
}
/** 수평공간(berm) 뒤 절토 경사로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
function bermCut(
src: OffsetPoint,
outward: number,
berm: { lengthM: number; slopeRatio: number },
groundAt: (offset: number) => number,
limitOffset: number,
): OutletFillSegment {
const ratio = Math.max(berm.slopeRatio, 0.1);
const flat = slopedCrossing(src, outward, 0, groundAt, limitOffset);
const segment = (points: OffsetPoint[]): OutletFillSegment => ({
points,
lengthM: points
.slice(1)
.reduce(
(sum, point, i) =>
sum + Math.hypot(point.offset - points[i].offset, point.elevation - points[i].elevation),
0,
),
overLimit: false,
kind: "cut",
});
if (flat !== null && (flat - src.offset) * outward <= berm.lengthM + 1e-9)
return segment([src, { offset: flat, elevation: src.elevation }]);
const bend = { offset: src.offset + outward * berm.lengthM, elevation: src.elevation };
const span = (limitOffset - bend.offset) * outward;
let previousGap = groundAt(bend.offset) - bend.elevation;
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const gap = groundAt(bend.offset + outward * t) - (bend.elevation + t / ratio);
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const cross = t - 0.25 * (1 - fraction);
return segment([
src,
bend,
{ offset: bend.offset + outward * cross, elevation: bend.elevation + cross / ratio },
]);
}
previousGap = gap;
}
return segment([src, bend]);
}
export interface OutletExtrasResult {
@@ -380,6 +426,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const tail = trailingFill(src, outward, groundAt);
segments.push(tail);
addable = tail.overLimit;
} else if (input.cutBerm) {
segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset));
} else {
const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset);
if (cross !== null) {
@@ -22,7 +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 {
bodyGuardAxes,
bodyGuardBase,
bodyGuardHeight,
mergeGuardTrim,
} from "./B06_Section_UI_Cross_Body_Guard";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
@@ -139,9 +144,18 @@ export function computeCulvertLayout(
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
// 세월교 · 박스 흙막이(73-4) — 자동 자리 = 본체 끝 · 높이 빈 칸 = 본체 끝 높이(79-7).
const body = culvert.body ? bodyGuardBase(section, groundSamples) : null;
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const end = body?.ends[spec.role];
const wall = revetWallSpec(
spec,
adjust,
culvert.hidden_pipe === true,
diameter,
end ? bodyGuardHeight(end) : null,
);
const lean = outlineLeanOf({
form: wall.form,
height_m: wall.pureHeight,
@@ -154,8 +168,7 @@ 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;
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
const bodyAxesOf = (role: "inlet" | "outlet", info: typeof inletInfo) => {
const end = body?.ends[role];
const spec = role === "inlet" ? inletWallSpec : outletWallSpec;
@@ -402,7 +415,7 @@ export function computeCulvertLayout(
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
let outletWallAnchor = outletAnchor;
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 경사길이 4m 지점. 관 길이
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이
// 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다.
const outletZeroAnchor = slopeZeroAnchor(
outletInfo.edge,
@@ -618,10 +631,7 @@ export function computeCulvertLayout(
// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그
// 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과
// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다.
// ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의
// `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로
// 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이
// 다시 어긋난다 — 사용자 판단 대기 중인 항목임(PLAN 3-5 ⓑ).
// 세월교 · 박스 흙막이는 수평공간 뒤 절토선도 그리므로 면적에 넣음(79-7).
const extendTrimSlope = (points: OffsetPoint[], outward: number): void => {
if (!points.length) return;
if (outward > 0) {
@@ -630,8 +640,8 @@ export function computeCulvertLayout(
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
extendTrimSlope(drawnFillPoints(extrasForArea, !!body), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);
const ownTrim: CulvertDesignTrim | null =
walls.length || basin
@@ -366,7 +366,7 @@ export interface PipeWallPlacement {
* 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용.
* · 기준(2026-08-23 사용자 확정): autoOffset·baseElevation0 = **성토선 길이 0
* 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본
* defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한.
* defaultD, 성토선 0 지점 · 79-5), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한.
* · x = 노견 연장 평행이동(안쪽 금지).
* · 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45).
*/
@@ -381,7 +381,7 @@ export function placePipeWall(input: {
/** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */
invertCap: number;
adjust: WallAdjust;
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
/** d 미지정(null) 시의 기본 자리 — 성토선 0 지점(제일 위 · 79-5). */
defaultD: number;
/** x 하한 — 기본 0(안쪽 금지) · 세월교 · 박스 흙막이는 본체 끝이 0점이라 안 막음(73-4). */
minX?: number;
@@ -5,8 +5,12 @@
* 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { REVET_EMBED_DEPTH_M, REVET_TRAP_TOP_M } from "./B06_Section_UI_Cross_Culvert_Const";
import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
import {
GUARD_CUT_BERM_M,
REVET_EMBED_DEPTH_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { fillWallBaseWidth } from "./B06_Section_UI_Cross_Culvert_Solve";
@@ -15,7 +19,7 @@ import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
// d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정).
// 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는
// 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다.
// 그 점 자체다(PIPE_WALL_DEFAULT_RUN_M = 0 · 79-5).
export const slopeZeroAnchor = (
edge: { offset_m: number },
outward: number,
@@ -38,8 +42,11 @@ export const outletPipeEnd = (wall: WallLayout): OffsetPoint => {
// 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라
// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 화면이 그리는 선(`outletFill.segments`)과
// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다.
export const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] =>
result.segments.filter((segment) => segment.kind !== "cut").flatMap((segment) => segment.points);
// 세월교 · 박스 흙막이(`withCut`)는 수평공간 뒤 절토선을 그리므로 넣음(79-7).
export const drawnFillPoints = (result: OutletExtrasResult, withCut = false): OffsetPoint[] =>
result.segments
.filter((segment) => withCut || segment.kind !== "cut")
.flatMap((segment) => segment.points);
interface SideInfo {
outward: number;
@@ -77,9 +84,18 @@ export function buildCulvertTiers(input: {
section_mode: section.design?.section_mode,
side: culvert?.hidden_pipe ? culvert.inlet.face_side : null,
});
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
const cutBerm = (spec: CulvertSideSpec | undefined) =>
culvert?.body
? {
lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0),
slopeRatio: section.design?.cut_slope_ratio ?? 1.0,
}
: undefined;
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const outletInput = {
cutBerm: cutBerm(culvert?.outlet),
startBottomElevation: input.outletBottom,
outward: input.outletInfo.outward,
groundAt,
@@ -93,6 +109,7 @@ export function buildCulvertTiers(input: {
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinInput = {
cutBerm: cutBerm(culvert?.inlet),
startBottomElevation: input.basinBottom,
outward: input.inletInfo.outward,
groundAt,
@@ -13,7 +13,7 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
* 성토선 물매 1:1.2 불변.
* d = 상하(m, 수평 성분, + = 사면 아래) — 성토선을 타고 대각 이동.
* **배관(유입·유출) 벽**은 0점 = 성토선 길이 0 지점(벽 상단 = 노견)인
* 절대값(2026-08-23 사용자 확정), null = 기본 자리(경사길이 4m 지점).
* 절대값(2026-08-23 사용자 확정), null = 기본 자리(성토선 0 지점 · 79-5).
* 추가 벽은 종전대로 자동 자리 기준 상대값(null = 0).
* h = 높이(m). 배관 벽은 **순수 높이**(바닥~상단, 관 아래 0.5 포함 —
* 2026-08-23), 추가 벽은 계산용(근입 0.5 위 기준선~상단). null = 기본값
+14 -3
View File
@@ -154,10 +154,21 @@ export function appendFordOverlay(
});
layer.append(hit);
}
// 측벽 판정면 = ㄴ(벽 + 바닥판)을 감싸는 가상 사각형(79-6) — 얇은 벽만 잡던 것을 넓힘.
for (const side of layout.sides) {
const wall = side.parts.find((part) => part.kind === "wall");
if (!wall) continue;
const hit = polygon(wall.points, "b06-chart__culvert-revet-hit", "");
const points = side.parts.flatMap((part) => part.points);
if (!points.length) continue;
const offsets = points.map((point) => point.offset);
const elevations = points.map((point) => point.elevation);
const [left, right] = [Math.max(...offsets), Math.min(...offsets)];
const [top, bottom] = [Math.max(...elevations), Math.min(...elevations)];
const box = [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
const hit = polygon(box, "b06-chart__culvert-revet-hit", "");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall(side.role);
@@ -106,6 +106,8 @@ export interface RevetmentLayout {
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
/** 기준 측점이 아닌 측점(구간 연동) — 주 형상과 색을 갈라 그림(79-6). */
linked: boolean;
}
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */
@@ -200,7 +202,7 @@ export function computeRevetmentLayout(
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
// 자동 자리 = 성토사면(1:1.2) 제일 위(노견 · 79-5) — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
* 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직
@@ -314,6 +316,7 @@ export function computeRevetmentLayout(
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
},
linked: Math.abs((Number(section.chainage_m) || 0) - spec.anchor_m) > 0.02,
};
}
@@ -387,6 +390,7 @@ export function appendRevetmentOverlay(
: "");
const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId);
if (onSelect) shape.classList.add("is-selectable");
if (layout.linked) shape.classList.add("is-linked");
drawn.push(shape);
});
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
+11 -11
View File
@@ -265,7 +265,13 @@ export function createCrossSectionCard(
card.applyRevetHighlight = (key) => {
highlightRevet = key;
setRevetActive(key);
// 옆 측점에서 고른 연동 구조물 = 주 횡단도도 강조(79-6) — 주인 카드만 세트를 가짐.
card.classList.toggle(
"b06-cross-card--link-owner",
!!key && !!section.culvert && key !== activeRevet,
);
};
card.applyRevetHighlight(highlightRevet);
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
@@ -434,11 +440,8 @@ export function createCrossSectionCard(
let ownDesignTrim: CulvertDesignTrim | undefined;
// 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다 — 2026-08-28 이관 이중그리기 방지).
// ⚠ 가드를 **좁혔다**(2026-09-09) — 예전에는 링크가 **있기만 하면** 막았는데,
// 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 **구조물을 놓아도
// 횡단도에 아무것도 안 보였다**(사용자에게 보이던 결함). 겹침 방지라는 까닭은
// 그대로 두고, **그 링크의 벽이 이 카드에 실제로 설 때만** 막는다.
// (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로
// 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다).
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
if (!section.culvert && !linkedWallsHere) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
@@ -545,6 +548,7 @@ export function createCrossSectionCard(
revetOffset ? toggleRevet : undefined,
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
isLinkedCulvert,
);
if (highlightRevet) setRevetActive(highlightRevet);
}
@@ -610,9 +614,7 @@ export function createCrossSectionCard(
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
const formOfWall = (key: RevetKey): string =>
culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT;
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다 — 본 기슭막이를 조절하면
// 링크된 측점이 같이 움직인다는 뜻이다(2026-08-24 사용자). 연동을 풀면 이 카드가
// 자기 값을 갖는다.
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값.
const adjustTarget = (): CrossSection =>
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
? culvertLink.source
@@ -675,9 +677,7 @@ export function createCrossSectionCard(
section.chainage_m,
Math.round((effectiveHalfWidth ?? maxOffset) + deltaM),
),
// ⤢ = 초기화. 기본은 **전역 횡단 반폭 보기값**으로 되돌리고, 그 폭 안에서는
// 절·성토선과 원지반의 교차점이 안 보이는 측점만 교차점이 보이는 폭으로 되돌린다
// (2026-08-23 사용자 지시 — 그 측점의 기본값이 그 폭이라는 뜻).
// ⤢ = 초기화 — 전역 횡단 반폭, 교차점이 안 보이는 측점만 보이는 폭(2026-08-23).
fit: () => {
const base = crossHalfWidth ?? maxOffset;
const needed = toeFitHalfWidth(section);
@@ -377,18 +377,21 @@ export function createBoxControls(deps: FordControlDeps): {
update: (chainageM, role, patch) => {
const current = adjustAt(chainageM);
const side: BoxSideAdjust = { ...current[role], ...patch };
write(chainageM, {
...current,
[role]: {
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
lengthM: Math.max(0, round1(side.lengthM)),
riseM: clampMove(side.riseM),
},
});
// 본체는 늘 수평(79-7) — 표고는 좌 · 우가 한 값.
const riseM = clampMove(side.riseM);
const other = role === "left" ? "right" : "left";
const next: BoxAdjust = { ...current, [other]: { ...current[other], riseM } };
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
next[role] = { lengthM: Math.max(0, round1(side.lengthM)), riseM };
write(chainageM, next);
},
reset: (chainageM, role) => {
const current = adjustAt(chainageM);
write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } });
const other = role === "left" ? "right" : "left";
// 표고는 좌 · 우 한 값(79-7) — 되돌리면 양쪽 표고가 함께 0.
const next: BoxAdjust = { ...current, [other]: { ...current[other], riseM: 0 } };
next[role] = { ...DEFAULT_BOX_SIDE_ADJUST };
write(chainageM, next);
},
setBody: (chainageM, patch) => {
const spec = sectionAt(chainageM)?.box;
@@ -283,7 +283,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
const current = revetShifts.get(key) ?? { ...ZERO_ADJUST, ...saved };
const next: WallAdjust = {
x: clampMove(patch.x ?? current.x),
// d: null = 기본 자리(배관 벽 — 경사길이 4m 지점) 유지.
// d: null = 기본 자리(배관 벽 — 성토선 0 지점 · 79-5) 유지.
d: patch.d === undefined ? current.d : patch.d === null ? null : clampMove(patch.d),
// 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다.
h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h),
@@ -169,6 +169,12 @@
box-shadow: 0 0 0 2px var(--color-danger);
}
/* 연동 구조물을 옆 측점에서 고를 때의 주 횡단도(79-6) — 선택 강조보다 한 단 옅게. */
.b06-cross-card--link-owner:not(.b06-cross-card--selected) {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
}
/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */
.b06-cross-card > header {
display: flex;
@@ -258,6 +258,13 @@
cursor: pointer;
}
/* 연동 형상(옆 측점에서 넘어온 벽 · 79-6) — 주 형상과 갈라 보이게 옅은 청회색 · 점선 테두리. */
.b06-chart__culvert-revet.is-linked {
fill: color-mix(in srgb, #6f8696 30%, transparent);
stroke: #6f8696;
stroke-dasharray: 4 2;
}
.b06-chart__culvert-revet.is-active {
fill: color-mix(in srgb, var(--color-danger) 45%, transparent);
stroke: var(--color-danger);
+2 -1
View File
@@ -213,9 +213,10 @@ export function guardSet(
diameterM: number,
): CulvertSetSpec | null {
const [inletKey, outletKey] = GUARD_KEYS[body];
// 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값
const side = (role: string, key: string): CulvertSetSpec =>
String(values[key] ?? "").trim() === "흙막이"
? revetSide(values, role)
? { ...revetSide(values, role), revet_berm_m: num(values[`${role}_revet_berm_m`], null) }
: { role, structure: "" };
const inlet = side("inlet", inletKey);
const outlet = side("outlet", outletKey);
@@ -3402,6 +3402,7 @@
"inlet.height": "높이 (m)",
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"inlet.berm": "수평공간 (m)",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
@@ -3417,6 +3418,7 @@
"outlet.height": "높이 (m)",
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"outlet.berm": "수평공간 (m)",
"soil_add": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
@@ -3471,6 +3473,7 @@
"inlet.height": "높이 (m)",
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"inlet.berm": "수평공간 (m)",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
@@ -3486,6 +3489,7 @@
"outlet.height": "높이 (m)",
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"outlet.berm": "수평공간 (m)",
"soil_add": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
+6 -8
View File
@@ -51,7 +51,6 @@ const B06 = (f) => path.join(ROOT, "B06_Section", f);
const { computeStoredLayouts, structureAreaRows, trimOfLayouts } = loadTs(
B06("B06_Section_Structure_Layouts.ts"),
);
const { outletPipeEnd } = loadTs(B06("B06_Section_UI_Cross_Culvert_Tiers.ts"));
const { bodyGuardBase } = loadTs(B06("B06_Section_UI_Cross_Body_Guard.ts"));
const fixture = JSON.parse(
@@ -61,7 +60,7 @@ const sections = fixture.cross_sections;
const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6);
const round = (v) => Math.round(v * 1000) / 1000;
/** 벽마다 전면(관 끝 자리) ↔ 본체 끝 차이(m) · 바닥 기준 ↔ 본체 끝 표고 차이. */
/** 벽마다 배면 ↔ 본체 끝 자리 차이(m) · 벽 윗면 ↔ 본체 윗면 표고 차이(79-7 — 벽 배면이 본체 끝에 붙음). */
function fronts(section) {
const layouts = computeStoredLayouts(section, sections);
const base = bodyGuardBase(section, section.samples);
@@ -69,13 +68,12 @@ function fronts(section) {
return {
walls: walls.map((w) => {
const end = base.ends[w.role];
const front = outletPipeEnd(w);
return {
role: w.role,
form: w.form,
dOffset: round(front.offset - end.offset),
dElevation: round(front.elevation - end.elevation),
front: round(front.offset),
dOffset: round(w.bottomBack.offset - end.offset),
dElevation: round(Math.max(...w.points.map((p) => p.elevation)) - end.top),
front: round(w.bottomBack.offset),
};
}),
bodyX: layouts.culvert?.bodyX ?? null,
@@ -102,8 +100,8 @@ const movedLayouts = computeStoredLayouts(moved, swapped);
const base = bodyGuardBase(moved, moved.samples);
out.boxMoved = movedLayouts.culvert.walls.map((w) => ({
role: w.role,
dOffset: round(outletPipeEnd(w).offset - base.ends[w.role].offset),
front: round(outletPipeEnd(w).offset),
dOffset: round(w.bottomBack.offset - base.ends[w.role].offset),
front: round(w.bottomBack.offset),
}));
// 연동 옆 측점(340 · 360) — 소유 측점 벽이 섬
+211
View File
@@ -0,0 +1,211 @@
/* 79 흙막이 · 세월교 · 박스 형상 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(검증_sub7 상세 일부) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_79_guard_shape.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, structureAreaRows } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
const { bodyGuardBase } = loadTs(B06("B06_Section_UI_Cross_Body_Guard.ts"));
const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom.ts"));
const { computeCulvertLayout } = loadTs(B06("B06_Section_UI_Cross_Culvert_Geom.ts"));
const { computeRevetmentLayout } = loadTs(B06("B06_Section_UI_Cross_Revetment.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 at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6);
const clone = (v) => JSON.parse(JSON.stringify(v));
const r3 = (v) => Math.round(v * 1000) / 1000;
const out = {};
/** 벽 윗면 · 바닥 · 전면(관 끝 자리) ↔ 본체 끝 윗면 · 바닥 · 자리. */
function guardFit(section, list = sections) {
const layouts = computeStoredLayouts(section, list);
const base = bodyGuardBase(section, section.samples);
return (layouts.culvert?.walls ?? []).map((w) => {
const end = base.ends[w.role];
const top = Math.max(...w.points.map((p) => p.elevation));
const bottom = Math.min(...w.points.map((p) => p.elevation));
return {
role: w.role,
outward: w.outward,
topGap: r3(top - end.top),
bottomGap: r3(bottom - end.bottom),
pureHeight: r3(w.height + 0.5),
endHeight: r3(end.top - end.bottom),
};
});
}
// ① 높이 빈 칸 = 본체 끝 높이 · 윗면 = 본체 윗면(세월교 · 박스)
const blank = (s) => {
const c = clone(s);
for (const role of ["inlet", "outlet"]) c.culvert[role].revet_height_m = null;
return c;
};
const boxBlank = blank(at(352.14));
const fordBlank = blank(at(173.087));
out.boxAuto = guardFit(
boxBlank,
sections.map((s) => (s === at(352.14) ? boxBlank : s)),
);
out.fordAuto = guardFit(
fordBlank,
sections.map((s) => (s === at(173.087) ? fordBlank : s)),
);
// 높이를 적으면 윗면은 그대로 · 바닥이 따라 움직임
out.boxSet = guardFit(at(352.14));
// ② 세월교 흙막이 쪽 ㄴ형 측벽 · 선 걷음
const fordLayouts = computeStoredLayouts(at(173.087), sections);
out.fordParts = fordLayouts.ford.sides.map((s) => ({ role: s.role, parts: s.parts.length }));
const fordOneGuard = clone(at(173.087));
fordOneGuard.culvert.outlet = { role: "outlet", structure: "" };
const oneList = sections.map((s) => (s === at(173.087) ? fordOneGuard : s));
out.fordOneGuardParts = computeStoredLayouts(fordOneGuard, oneList).ford.sides.map((s) => ({
role: s.role,
parts: s.parts.length,
}));
// ③ 박스 본체 수평 — 좌 · 우 표고 조정이 달라도
const box = at(352.14);
const tilted = computeBoxLayout(box, box.samples, {
left: { lengthM: 0, riseM: 0.6 },
right: { lengthM: 0, riseM: 0 },
});
out.boxLevel = {
left: r3(tilted.sides[0].topElevation),
right: r3(tilted.sides[1].topElevation),
slope: tilted.slopeRatio,
};
// ④ 박스 날개벽 쪽 끝 절 · 성토선(흙막이 없는 박스)
const boxWing = clone(box);
delete boxWing.culvert;
const wingLayout = computeBoxLayout(boxWing, boxWing.samples);
out.boxWing = wingLayout.sides.map((s) => ({
role: s.role,
cut: s.cutLine ? 1 : 0,
fill: s.fillSegments.length,
}));
// ⑤ 흙막이 끝 절토 = 수평공간 뒤 절토 경사 — 수평공간 칸 값을 따름
function cutTails(section, list) {
const culvert = computeStoredLayouts(section, list).culvert;
return [...culvert.outletFill.segments, ...culvert.basinFill.segments]
.filter((g) => g.kind === "cut")
.map((g) => ({
points: g.points.length,
berm: r3(Math.abs(g.points[1].offset - g.points[0].offset)),
flat: r3(g.points[1].elevation - g.points[0].elevation),
}));
}
out.cutDefault = [cutTails(at(352.14), sections), cutTails(at(173.087), sections)];
const berm2 = clone(at(352.14));
berm2.culvert.inlet.revet_berm_m = 2;
berm2.culvert.outlet.revet_berm_m = 2;
out.cutBerm2 = cutTails(
berm2,
sections.map((s) => (s === at(352.14) ? berm2 : s)),
);
// ⑥ 관 보호공 기본 자리 = 제일 위(성토선 0 지점 · d 0) · 관 길이 m 단위
const pipe = clone(at(180));
pipe.culvert = {
type: "pipe",
chainage_m: 180,
pipe_kind: "흄관",
diameter_m: 1.0,
min_cover_m: 0.5,
inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
};
pipe.design.inlet_structure = "revet";
const pipeLayout = computeCulvertLayout(pipe, pipe.samples, undefined, "revet");
out.pipe = pipeLayout
? {
d: { inlet: pipeLayout.revetShift.inlet.d, outlet: pipeLayout.revetShift.outlet.d },
walls: pipeLayout.walls.map((w) => ({
role: w.role,
topBelowEdge: r3(
(w.outward > 0 ? pipe.design.road_edges.left : pipe.design.road_edges.right).elevation_m -
w.topJoint.elevation,
),
})),
lengthM: pipeLayout.pipe.lengthM,
}
: null;
// ⑦ 독립 기슭막이 · 옹벽 기본 자리 = 제일 위(d 0)
const revet = clone(at(180));
revet.revetment = {
structure_id: "t",
type_id: "masonry_wet",
name: "t",
start_m: 170,
end_m: 190,
anchor_m: 180,
form: "돌쌓기(찰)",
height_m: 2,
side: "우",
};
const own = computeRevetmentLayout(revet, null);
out.revet = own
? {
d: own.appliedAdjust.d,
topBelowEdge: r3(revet.design.road_edges.right.elevation_m - own.top.elevation),
linked: own.linked,
}
: null;
const neighbour = clone(revet);
neighbour.chainage_m = 170;
out.revetLinked = computeRevetmentLayout(neighbour, null)?.linked ?? null;
out.areas = structureAreaRows(sections).filter((r) =>
[173.087, 352.14].some((c) => Math.abs(r.chainage_m - c) < 0.6),
);
process.stdout.write(JSON.stringify(out, null, 1));
+2 -1
View File
@@ -32,9 +32,10 @@ GUARD = [
["structure", None, None],
["form", ["흙막이"], "돌쌓기(찰)"],
["length", ["흙막이"], "10"],
["height", ["흙막이"], "1.5"],
["height", ["흙막이"], None], # 빈 칸 = 본체 끝 높이(79-7 · 횡단도가 셈)
["before", ["흙막이"], "5"],
["after", ["흙막이"], "5"],
["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7)
]
+5 -4
View File
@@ -31,7 +31,7 @@ def screen() -> dict:
return json.loads(done.stdout)
def test_박스_벽_전면이_본체_끝(screen: dict) -> None:
def test_박스_벽_배면이_본체_끝_윗면이_본체_윗면(screen: dict) -> None:
walls = screen["box"]["walls"]
assert {w["role"] for w in walls} == {"inlet", "outlet"}
for wall in walls:
@@ -50,12 +50,13 @@ def test_박스를_늘리면_벽도_m_단위로_따라감(screen: dict) -> None:
assert abs(wall["front"] - before[wall["role"]]) == pytest.approx(1.0, abs=1e-3)
def test_세월교_벽_전면이_관_끝(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)
assert walls["outlet"]["dElevation"] == pytest.approx(0, abs=1e-3)
# 유입 2.5 m 벽은 관 끝 자리에선 노면 위로 솟음 — 배관 벽과 같이 상단이 노견에서 멈춰 내려앉음
assert walls["inlet"]["dElevation"] <= 0
assert walls["inlet"]["dOffset"] == pytest.approx(0, abs=1e-3)
# 윗면 = 측벽 상단(상판 높이) — 노면 위로는 안 솟음(79-7)
assert walls["inlet"]["dElevation"] <= 1e-3
def test_연동_옆_측점에도_벽이_섬(screen: dict) -> None:
+96
View File
@@ -0,0 +1,96 @@
"""79 흙막이 · 세월교 · 박스암거 형상 — 화면 TS 를 그 자리에서 돌린 값(`helper_79_guard_shape.cjs`).
픽스처 = 73-4 와 같은 검증_sub7 상세 일부(세월교 173.09 · 박스 352.14 유입 · 유출 흙막이).
- 79-5 관 보호공 · 독립 기슭막이(옹벽) 기본 자리 = 성토사면 제일 위(d 0 · 관은 m 단위 맞춤만큼 아래)
- 79-6 구간 연동 측점 벽 = 연동 표시
- 79-7 흙막이 윗면 = 본체 윗면 · 높이 빈 칸 = 본체 끝 높이 · 세월교 ㄴ형 측벽 걷음 · 박스 수평 ·
박스 날개벽 끝 절 · 성토선 · 끝 절토 = 수평공간(컨테이너 칸) 뒤 절토 경사
"""
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_79_guard_shape.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def shape() -> 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)
@pytest.mark.parametrize("key", ["boxAuto", "fordAuto"])
def test_높이_빈칸은_본체_끝_높이_윗면은_본체_윗면(shape: dict, key: str) -> None:
walls = shape[key]
assert {w["role"] for w in walls} == {"inlet", "outlet"}
for wall in walls:
assert wall["topGap"] == pytest.approx(0, abs=1e-3)
assert wall["bottomGap"] == pytest.approx(0, abs=1e-3)
assert wall["pureHeight"] == pytest.approx(wall["endHeight"], abs=1e-3)
def test_높이를_적으면_윗면은_그대로_바닥이_따라감(shape: dict) -> None:
for wall in shape["boxSet"]:
assert wall["topGap"] == pytest.approx(0, abs=1e-3)
assert wall["bottomGap"] == pytest.approx(wall["endHeight"] - wall["pureHeight"], abs=1e-3)
def test_세월교_흙막이_쪽_ㄴ형_측벽은_사라짐(shape: dict) -> None:
assert all(side["parts"] == 0 for side in shape["fordParts"])
one = {side["role"]: side["parts"] for side in shape["fordOneGuardParts"]}
assert one == {"inlet": 0, "outlet": 2}
def test_박스_본체는_늘_수평(shape: dict) -> None:
level = shape["boxLevel"]
assert level["left"] == pytest.approx(level["right"], abs=1e-6)
assert level["slope"] is None
def test_박스_날개벽_끝_절성토선(shape: dict) -> None:
for side in shape["boxWing"]:
assert side["cut"] + side["fill"] >= 1
def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None:
tails = [tail for group in shape["cutDefault"] for tail in group]
assert tails, "절토 끝선이 없음"
for tail in tails:
assert tail["points"] == 3
assert tail["flat"] == pytest.approx(0, abs=1e-6)
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
for tail in shape["cutBerm2"]:
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None:
pipe = shape["pipe"]
assert pipe is not None
assert pipe["d"]["inlet"] == pytest.approx(0, abs=1e-6)
# 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07)
assert pipe["d"]["outlet"] < 1.0
inlet = next(w for w in pipe["walls"] if w["role"] == "inlet")
assert inlet["topBelowEdge"] == pytest.approx(0, abs=0.05)
assert float(pipe["lengthM"]).is_integer()
def test_독립_기슭막이_옹벽_기본_자리는_제일_위(shape: dict) -> None:
revet = shape["revet"]
assert revet["d"] == 0
assert revet["topBelowEdge"] == pytest.approx(0, abs=1e-3)
assert revet["linked"] is False
assert shape["revetLinked"] is True
+3 -2
View File
@@ -230,8 +230,9 @@ def test_다단_성토부선이_면적_트림에_들어간다():
assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐"
# 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다.
# 면적용은 확정 단까지만(`extrasForArea` — 2026-09-14 ㉠ 미확정 단은 성토를 안 깎음).
assert "extendTrimSlope(drawnFillPoints(extrasForArea)" in source, "유출측 다단이 빠짐"
assert "extendTrimSlope(drawnFillPoints(basinExtrasForArea)" in source, (
# 둘째 인자 = 세월교 · 박스 흙막이의 수평공간 뒤 절토선 포함 여부(79-7).
assert "extendTrimSlope(drawnFillPoints(extrasForArea," in source, "유출측 다단이 빠짐"
assert "extendTrimSlope(drawnFillPoints(basinExtrasForArea," in source, (
"집수정 계류측 다단이 빠짐"
)
# 그리지 않는 `cut` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다.