diff --git a/A00_Common/design/design_structure_box.ts b/A00_Common/design/design_structure_box.ts index 53a42c1d2..4f2b8b480 100644 --- a/A00_Common/design/design_structure_box.ts +++ b/A00_Common/design/design_structure_box.ts @@ -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, "박스 높이(자동)"), ], }); diff --git a/A00_Common/design/design_structure_ford.ts b/A00_Common/design/design_structure_ford.ts index 9a1213533..4b12e1ed5 100644 --- a/A00_Common/design/design_structure_ford.ts +++ b/A00_Common/design/design_structure_ford.ts @@ -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, "본체 끝 높이(자동)"), ], }); diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index 1f6eae6cf..e1b4fbdb5 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -82,6 +82,23 @@ export const revetKeys = (side: "inlet" | "outlet"): Record => ( 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", diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index da41064f2..7147f89a0 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -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" }); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 3a86c1259..0bab3c485 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -42,6 +42,8 @@ export const FORM_OPTION_KEYS: Readonly> = { "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> = { "wing_out", ...revetKeys("inlet"), ...revetKeys("outlet"), + "inlet_revet_berm_m", + "outlet_revet_berm_m", ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index f7fbd928f..ae320539e 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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. */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ea79fcce4..9f0631eec 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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 { diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 8b6e403bb..c22bbefc1 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index 714bfaaf6..fcafb2c66 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -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; + ends: Record; 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( layout: T | null, culvert: CulvertLayout | null, @@ -138,13 +168,15 @@ export function withoutGuardedFill( 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; } diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index 5e6e46382..c83a1a607 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index 66808640d..a9ae4f1b3 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -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 }, }; }; diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 66eef9cd2..063233d09 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -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([ "⚠ 기슭막이 높이 없음 — 근입만 그림", diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 06ac4eb63..e8e02a3c4 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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); // 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 346b2a5dc..e4bad48ea 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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). 기슭막이를 자연 사면 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index e526ca91e..f516c140e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -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) { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 5fd3137a6..3cdce0936 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index c61618231..d45f2a726 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index bf7045d78..6396cc06f 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index b18c8abaa..1c8af8d03 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -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 = 기본값 diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 81879e0b5..f21cc38ca 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 15bb33634..f73a30df1 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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); }); // 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를 diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index c46b44c28..0ad30e724 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 58f64ced8..91825ef70 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index c65676d57..e41f661d6 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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() ?? ""; diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 3f3a037ec..45faf329c 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 6f56403f8..70179e165 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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), diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 15b6370d0..af1dd0afb 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -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; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index a1d401a9c..d240917ba 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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); diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index d8e8ae86c..235142da7 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -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); diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index d4b775f26..32a69a793 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -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=돌쌓기(찰)": "돌 쌓기(찰)", diff --git a/resources/tester/helper_73_4_body_guard.cjs b/resources/tester/helper_73_4_body_guard.cjs index 8240ab034..71de877e6 100644 --- a/resources/tester/helper_73_4_body_guard.cjs +++ b/resources/tester/helper_73_4_body_guard.cjs @@ -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) — 소유 측점 벽이 섬 diff --git a/resources/tester/helper_79_guard_shape.cjs b/resources/tester/helper_79_guard_shape.cjs new file mode 100644 index 000000000..69a30ad20 --- /dev/null +++ b/resources/tester/helper_79_guard_shape.cjs @@ -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)); diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index ca0801634..052826723 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -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) ] diff --git a/resources/tester/test_73_4_body_guard.py b/resources/tester/test_73_4_body_guard.py index 15eb8cd7d..dc39b4c33 100644 --- a/resources/tester/test_73_4_body_guard.py +++ b/resources/tester/test_73_4_body_guard.py @@ -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: diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py new file mode 100644 index 000000000..f33cbcafe --- /dev/null +++ b/resources/tester/test_79_guard_shape.py @@ -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 diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 7fb7893e3..36a7abf5d 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -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` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다. diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 956e9dccb..ee0678e37 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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})"],