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

# Conflicts:
#	B06_Section/B06_Section_UI_Cross_View.ts
This commit is contained in:
2026-10-08 00:28:26 +09:00
37 changed files with 771 additions and 131 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) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) ·
* 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */
export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] {
const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) =>
item.id === "height" ? { ...item, default: undefined, hint: heightHint } : 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;
+52 -20
View File
@@ -3,8 +3,10 @@
* 세월교 · 박스 유입 · 유출 흙막이(73-4) — 관보호공 흙막이와 **같은 벽 · 같은 자리 규칙**.
*
* 세트는 배관 흙막이와 같은 `section.culvert`(관 숨김 · `body`)로 얹혀 그림 · 연동 · 경사 · 3D ·
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 전면이 관(세월교) · 본체(박스) 끝에 옴
* (배관 벽 전면이 관 끝인 것과 같음). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 **배면(수직)이 본체 끝**에 붙어 벽 몸이 본체
* 바깥에 섬 · 벽 윗면 = 본체 윗면(세월교 상판 · 박스 상판) · 높이 기본 = 세월교 본체 끝 높이 ·
* 박스 높이 값(2 m 박스 → 2 m · 79b · 바닥 = 윗면 − 높이)(79-7 · 그림
* `tmp/79_세월교_기본위치.png`). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 본체가 이 측점에 없으면(연동 옆 측점) 배관 규칙 그대로 — 소유 측점 값을 절대값으로 넘겨 받음.
* ========================================================================== */
@@ -12,11 +14,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 +26,17 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
type Role = "inlet" | "outlet";
/** 본체 끝(관 invert · 박스 저판 밑) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) 표고 · 흙막이 기본 순수 높이(79-7 · 79b). */
export interface BodyEnd {
offset: number;
top: number;
bottom: number;
height: number;
}
/** 본체 끝(세월교 관 끝 · 박스 끝) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
export interface BodyGuardBase {
ends: Record<Role, OffsetPoint | null>;
ends: Record<Role, BodyEnd | null>;
trim: CulvertDesignTrim;
}
@@ -45,11 +53,19 @@ 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,
// 박스 높이 값 그대로(사용자 원문 2 m → 2 m · 79b)
height: layout.box.inner_height_m,
}
: null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
@@ -59,19 +75,32 @@ 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,
height: side.top.elevation - (side.slabTopElevation - layout.ford.slab_thickness_m),
}
: null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
return null;
}
/** 흙막이 기본 순수 높이(근입 포함) — 세월교 = 본체 끝 높이 · 박스 = 박스 높이 값(79-7 · 79b). */
export const bodyGuardHeight = (end: BodyEnd): number => end.height;
/**
* 벽 전면을 본체 끝에 맞춘 배관 벽 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 +110,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 +122,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 +158,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 +168,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;
}
+25 -1
View File
@@ -80,7 +80,8 @@ export function appendBoxOverlay(
side.apron,
"b06-chart__ford-slab",
`${mark}측 날개벽 구간 바닥 — 수평 · 두께 ${box.slab_thickness_m.toFixed(2)}m` +
` (날개벽 ${wingText(side.role === "left" ? box.wing_in : box.wing_out)} 투영 연장)`,
` (${side.wingRole === "inlet" ? "유입" : "유출"} 날개벽 ` +
`${wingText(side.wingRole === "inlet" ? box.wing_in : box.wing_out)} 투영 연장)`,
),
);
}
@@ -126,6 +127,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);
+51 -7
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,12 @@ export interface BoxSideLayout {
apron: OffsetPoint[];
/** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */
fillLine: OffsetPoint[];
/** 끝 바깥 성토부선 — 끝이 원지반 위로 나올 때(세월교와 같은 체계 · 79-7). */
fillSegments: OutletFillSegment[];
/** 끝이 원지반에 묻힐 때의 절토선(계획선). */
cutLine: BasinLayout["cutLine"];
/** 이 끝의 날개벽 칸 — 유입(상단측) · 유출(툴팁 이름 · 79b). */
wingRole: "inlet" | "outlet";
/** 적용된 조작값(한계에 걸린 뒤). */
adjust: BoxSideAdjust;
}
@@ -119,19 +132,24 @@ 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);
const bottomElevation = topElevation - bodyHeight;
// 날개벽 구간 바닥 — 각 끝 표고에서 **수평**(2026-08-25 사용자).
const wing = (role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
const wingRole = (role === "left") === inletOnLeft ? "inlet" : "outlet";
const wing = wingRole === "inlet" ? box.wing_in : box.wing_out;
const extend = Math.max(wing.slab_extend_m, 0);
const outer = offset + outward * extend;
const apron: OffsetPoint[] = extend
@@ -148,6 +166,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 +196,10 @@ export function computeBoxLayout(
bottomElevation,
apron,
fillLine,
adjust: { lengthM, riseM: wanted.riseM },
fillSegments,
cutLine,
wingRole,
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,16 +184,26 @@ 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)이다. 기본 자리가 안 되는 지형(매몰·역경사·
* 토피)은 배치 풀이의 기존 한계 논리가 안쪽으로 되돌린다.
* 좌우(x) 원점 = 노견에서 성토사면 경사길이 4m 지점(2026-08-23 · 독립 기슭막이 · 옹벽의 x 원점).
* 상하(d) 조작의 0점은 성토선 길이 0 지점(벽 상단 = 노견)이고, d는 1:1.2 성토선을 타고
* 내려간 수평 성분(m)이다. 저장된 x 는 이 원점 기준이라 절대 자리가 그대로다(79b).
*/
export const PIPE_WALL_DEFAULT_SLOPE_M = 4.0;
/** 기본 자리(경사길이 4m)의 수평 성분(m) — d 기본값. */
/** x 원점(경사길이 4m)의 수평 성분(m). */
/** 세월교 · 박스 흙막이 절토부 앞 수평공간 기본값(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);
/**
* 관 보호공 d(상하) 빈 칸의 기본값 — x 도 비었으면 성토사면 **제일 위**(0 · 79-5), 손으로 x 를
* 넣어 둔 벽은 종전 자리(경사길이 4m)라 절대 자리가 안 바뀜(79b). 관은 그 위에서 m 단위 맞춤이
* 유출 벽을 사면 아래로 민다.
*/
export const pipeWallDefaultD = (adjust: { x: number }): number =>
Math.abs(adjust.x) > 1e-9 ? PIPE_WALL_DEFAULT_RUN_M : 0;
/**
* 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어
* 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면
@@ -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) {
@@ -9,7 +9,7 @@
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
revetWallSpec,
PIPE_WALL_DEFAULT_RUN_M,
pipeWallDefaultD,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
@@ -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;
@@ -237,7 +250,7 @@ export function computeCulvertLayout(
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
adjust: onBody(adjInlet, inletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
defaultD: pipeWallDefaultD(adjInlet),
minX,
});
appliedAdjust.inlet.x = placed.x - (inletBody?.x ?? 0);
@@ -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,
@@ -426,7 +439,7 @@ export function computeCulvertLayout(
// 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
adjust: onBody(adjOutlet, outletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
defaultD: pipeWallDefaultD(adjOutlet),
minX,
});
appliedAdjust.outlet.x = placed.x - (outletBody?.x ?? 0);
@@ -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) · x 를 넣은 벽은 4m 지점(79b). */
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)이다.
// 그 점 자체다 · 손으로 x 를 넣은 벽만 경사길이 4m 지점(`pipeWallDefaultD` · 79-5 · 79b).
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);
+17 -9
View File
@@ -106,6 +106,8 @@ export interface RevetmentLayout {
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
/** 기준 측점이 아닌 측점(구간 연동) — 주 형상과 색을 갈라 그림(79-6). */
linked: boolean;
}
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */
@@ -200,20 +202,25 @@ 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) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
// x 원점 = 성토사면(1:1.2) 경사길이 4m 지점 — 저장된 x 의 절대 자리 그대로(79b).
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
* 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직
* 하강(m)**. 성토선 각도는 그대로 두고 **노견 확장 구간**이 길이를 흡수한다
* (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다).
*/
const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN;
const requestedX = Math.max(adjust?.x ?? 0, 0);
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */
const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun);
// 손대지 않은 벽(x · d 빈 칸) = 성토사면 제일 위(노견 · 79-5) · 손으로 넣은 벽은 종전 기본(79b).
const untouched = adjust?.d == null && !adjust?.x;
const defaultDrop = untouched ? 0 : autoRun / FILL_SLOPE_RATIO_MIN;
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리 — 음수 = 원점보다
* 사면 위(노견 쪽)라 제일 위 자리는 x = −autoRun 이다. */
const xFloor = (drop: number): number => FILL_SLOPE_RATIO_MIN * drop - autoRun;
const requestedX = untouched ? Number.NEGATIVE_INFINITY : (adjust?.x ?? 0);
// 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). 노면 위 금지 = d ≥ 0.
const appliedDrop = Math.round(Math.max(adjust?.d ?? defaultDrop, 0) * 10) / 10;
const appliedX = Math.round(Math.max(requestedX, xFloor(appliedDrop)) * 10) / 10;
// 0.1 눈금 반올림(종전과 같음) · 반올림이 하한 아래로 내리면 하한(제일 위 자리가 노견 안쪽으로 안 감).
const floor = xFloor(appliedDrop);
const appliedX = Math.max(Math.round(Math.max(requestedX, floor) * 10) / 10, floor);
const anchorOffset = autoOffset + outward * (autoRun + appliedX);
const baseElevation = edge.elevation_m - appliedDrop - height;
@@ -236,9 +243,8 @@ export function computeRevetmentLayout(
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
// 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다.
const floorNow = xFloor(appliedDrop);
if (requestedX > 0.005 && floorNow > requestedX + 0.005) {
tier1.shiftFloorM = Math.round(floorNow * 100) / 100;
if (requestedX > 0.005 && floor > requestedX + 0.005) {
tier1.shiftFloorM = Math.round(floor * 100) / 100;
}
// 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로.
@@ -314,6 +320,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 +394,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);
});
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
@@ -60,6 +60,8 @@ export interface StructurePanelDeps {
/** 독립 기슭막이(관 숨김) 카드인가 — 유입·유출 벽이 같은 성토 로직이라 조정창도
* 좌우 같은 행을 준다(집수정 형식 숨김 · 유입측 단 수 행 표시 — 2026-08-29). */
hiddenPipe: () => boolean;
/** 세월교 · BOX암거 흙막이 세트인가 — 조정창 제목을 「흙막이」로(79b). */
bodyGuard?: () => boolean;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
@@ -517,16 +519,19 @@ export function buildStructurePanel(
key === "inlet" && (structure === "I" || structure === "L" || structure === "U");
// 독립 기슭막이(관 숨김)의 기준벽은 관이 없어 유입·유출이라는 이름이 안 맞는다 —
// 좌·우 어느 쪽이든 "기슭막이(독립)"으로 적는다(2026-08-29 사용자).
const guardKey = key === "inlet" || key === "outlet";
title.textContent =
isOwn || (deps.hiddenPipe() && (key === "inlet" || key === "outlet"))
? L("B06_Cross_Revet_Own")
: isExtra
? L("B06_Cross_Revet_Extra").replace("{n}", String(extraIndex + 1))
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
deps.bodyGuard?.() && guardKey
? L(key === "inlet" ? "B06_Cross_Guard_Inlet" : "B06_Cross_Guard_Outlet")
: isOwn || (deps.hiddenPipe() && guardKey)
? L("B06_Cross_Revet_Own")
: isExtra
? L("B06_Cross_Revet_Extra").replace("{n}", String(extraIndex + 1))
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 이동 조작은 기슭막이(집수정 제외) · 집수정은 위치 십자.
dragKind = isBasin ? "basin" : movable ? "wall" : null;
moveRow.row.classList.toggle("is-hidden", !movable);
+11 -8
View File
@@ -264,7 +264,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) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
@@ -433,11 +439,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");
@@ -544,6 +547,7 @@ export function createCrossSectionCard(
revetOffset ? toggleRevet : undefined,
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
isLinkedCulvert,
);
if (highlightRevet) setRevetActive(highlightRevet);
}
@@ -611,9 +615,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
@@ -644,6 +646,7 @@ export function createCrossSectionCard(
appliedD: () => culvertAppliedD,
inletIsBasin: () => culvertInletIsBasin,
hiddenPipe: () => culvertHiddenPipe,
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
inletOptions: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
@@ -71,6 +71,8 @@ export interface StructurePanelContext {
inletIsBasin: () => boolean;
/** 기준벽이 독립 기슭막이(관 없는 숨김 세트)인가 — 조정창 걸음 분류에 쓴다. */
hiddenPipe: () => boolean;
/** 세월교 · BOX암거 흙막이 세트인가(링크 카드는 소유 측점 세트 · 79b). */
bodyGuard?: () => boolean;
inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean };
extraState: () => { canAdd: boolean; count: number };
basinExtraState: () => { canAdd: boolean; count: number };
@@ -148,6 +150,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
hiddenPipe: () => ctx.hiddenPipe(),
bodyGuard: () => ctx.bodyGuard?.() ?? false,
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
const active = ctx.activeRevet() ?? "";
@@ -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) — 소유 측점 벽이 섬
+232
View File
@@ -0,0 +1,232 @@
/* 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),
autoHeight: r3(end.height),
};
});
}
// ① 높이 빈 칸 = 본체 끝 높이 · 윗면 = 본체 윗면(세월교 · 박스)
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;
// ⑥-2 손으로 x 를 넣은 관 보호공 = 종전 자리(d 기본 = 경사길이 4m 지점 · x 원점 그대로 · 79b)
const pipeX = computeCulvertLayout(
pipe,
pipe.samples,
{ outlet: { x: 1, d: null, h: null, m: null } },
"revet",
);
out.pipeStoredX = pipeX ? { x: pipeX.revetShift.outlet.x, d: r3(pipeX.revetShift.outlet.d) } : 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;
// 저장 x 가 있는 벽 = 종전 자리 그대로(x 원점 = 경사길이 4m 지점 · d 빈 칸 = 종전 기본 · 79b)
const autoX = computeRevetmentLayout(revet, null).walls[0].bottomBack.offset;
const storedX = computeRevetmentLayout(revet, { x: 1, d: null });
const storedXD = computeRevetmentLayout(revet, { x: 1, d: 1 });
out.revetStoredX = {
x: storedX.appliedAdjust.x,
d: storedX.appliedAdjust.d,
// 노견 → 4m 지점(3.07) + x 1 = 제일 위 자리에서 바깥 4.07 m
shift: r3(Math.abs(storedX.walls[0].bottomBack.offset - autoX)),
shiftXD: r3(Math.abs(storedXD.walls[0].bottomBack.offset - autoX)),
};
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:
+122
View File
@@ -0,0 +1,122 @@
"""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)
def test_세월교_높이_빈칸은_본체_끝_높이_윗면은_본체_윗면(shape: dict) -> None:
walls = shape["fordAuto"]
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:
# 2 m 박스 → 2 m(사용자 원문 숫자 · 79b) — 바닥 = 윗면 − 그 높이
walls = shape["boxAuto"]
assert {w["role"] for w in walls} == {"inlet", "outlet"}
for wall in walls:
assert wall["topGap"] == pytest.approx(0, abs=1e-3)
assert wall["pureHeight"] == pytest.approx(wall["autoHeight"], abs=1e-3)
assert wall["autoHeight"] < wall["endHeight"]
assert wall["bottomGap"] == pytest.approx(wall["endHeight"] - wall["autoHeight"], 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_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None:
# d 빈 칸 기본 = 경사길이 4 m 지점(3.07) — x 원점 그대로라 절대 자리 안 바뀜(79b)
stored = shape["pipeStoredX"]
assert stored["x"] == pytest.approx(1.0)
assert stored["d"] == pytest.approx(4.0 * 1.2 / (1 + 1.2**2) ** 0.5, abs=0.05)
def test_저장_x_있는_독립_기슭막이_옹벽은_종전_자리_그대로(shape: dict) -> None:
stored = shape["revetStoredX"]
assert stored["x"] == pytest.approx(1.0)
assert stored["d"] == pytest.approx(2.6) # 종전 기본 하강(3.07 / 1.2 → 0.1 눈금)
run = 4.0 * 1.2 / (1 + 1.2**2) ** 0.5
assert stored["shift"] == pytest.approx(run + 1.0, abs=1e-3)
assert stored["shiftXD"] == pytest.approx(run + 1.0, abs=1e-3)
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` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다.
+3
View File
@@ -254,6 +254,9 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"],
/* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */
B06_Cross_Revet_Own: ["기슭막이(독립)", "Revetment (standalone)"],
/* 세월교 · BOX암거 유입 · 유출 흙막이 조정창(79b). */
B06_Cross_Guard_Inlet: ["흙막이(유입)", "Retaining wall (inlet)"],
B06_Cross_Guard_Outlet: ["흙막이(유출)", "Retaining wall (outlet)"],
B06_Cross_Struct_Label: ["구조물 형식", "Structure type"],
// 조정창 제목은 "기슭막이(…)" 한 형식으로 통일한다(2026-08-29 사용자). {n} = 단 번호.
B06_Cross_Revet_Extra: ["기슭막이(추가 {n})", "Revetment (extra {n})"],