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:
@@ -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),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
"⚠ 기슭막이 높이 없음 — 근입만 그림",
|
||||
|
||||
@@ -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 = 기본값
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=돌쌓기(찰)": "돌 쌓기(찰)",
|
||||
|
||||
@@ -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) — 소유 측점 벽이 섬
|
||||
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다.
|
||||
|
||||
Reference in New Issue
Block a user