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:
@@ -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) — 높이 빈 칸 = 자동(`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",
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
"⚠ 기슭막이 높이 없음 — 근입만 그림",
|
||||
|
||||
@@ -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 = 기본값
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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));
|
||||
@@ -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,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
|
||||
@@ -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` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다.
|
||||
|
||||
@@ -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})"],
|
||||
|
||||
Reference in New Issue
Block a user