feat(B05,B06): 독립 기슭막이를 배수관 시설 종류로 이관

독립 기슭막이(구조물 정본 D군 구간형)를 배수관 측점(pipe_points)의 시설 종류
"revetment"로 옮긴다. 관을 숨긴(hidden_pipe) 배관 세트로 얹혀 컴퓨트·렌더·패널·
연동·경사·3D·수량이 배관 경로를 그대로 탄다.

- common_util_drainage_pipes: PIPE_FACILITY_REVET 추가, 여유고 0
- B06_Section_Engine_Culvert: _revet_set/_revet_side — 관 숨김 세트 생성
- B06_Section_Router: attach_revetments 제거(관 세트에 통합)
- B05_Profile_Engine_Sections: 기슭막이는 시작·기준·종료 세 측점 심기
- B05_Profile_Structure_Types.json: revetment placement=point, managed_by=pipe_points,
  설치 측에 "양쪽" 추가
- B05_Profile_UI_Drainage_Facility: 기슭막이 옵션 폼(설치 측·형태·높이·길이·전후·단 수)
- B06_Section_UI_Cross_Culvert_Geom: 관 숨김 시 관경 하한·역경사 클램프·유출 가드 해제
- B06_Section_UI_Cross_Culvert_Wire: restrictToSide — 설치 측(좌/우)만 남기기
- B06_Section_UI_Cross_View: 옛 D군 경로 이중그리기 가드, appliedAdjust 되받기
- B06_Section_UI_Cross_Wall: 기슭막이 벽 공용 기하·그리기 층 신설
- B05_Profile_UI_Corridor_Structures: 3D도 같은 규칙(관 실린더 숨김, 관통 컷 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 19:09:28 +09:00
co-authored by Claude Opus 5
parent 0050adf7d2
commit f916aa52a1
17 changed files with 636 additions and 263 deletions
+2 -1
View File
@@ -439,13 +439,14 @@ export type PipeSource = "stream" | "spacing" | "user";
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge";
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
["pipe", "배수관"],
["box_culvert", "BOX암거"],
["ford_pavement", "물넘이포장"],
["ford_bridge", "세월교"],
["revetment", "기슭막이"],
];
export interface DetailPipePoint {
+11 -1
View File
@@ -29,6 +29,7 @@ from B05_Profile.B05_Profile_Structures_Schema import (
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
PipePoint,
parse_pipe_points,
pipe_anchor_clearances,
@@ -174,7 +175,16 @@ def resolve_extra_stations(
if definition is None:
continue
label = definition.name
derived.append((float(pipe.chainage_m), label))
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
# 기준 한 곳(관이 노선을 가로지르는 점형).
if pipe.facility == PIPE_FACILITY_REVET:
marks = (pipe.start_m, pipe.chainage_m, pipe.end_m)
else:
marks = (pipe.chainage_m,)
for mark in marks:
if mark is not None:
derived.append((float(mark), label))
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
# structures.json에 저장되지 않아 중복되지 않는다.
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
+4 -3
View File
@@ -976,7 +976,8 @@
"type_id": "revetment",
"group": "D",
"name": "기슭막이",
"placement": "interval",
"placement": "point",
"managed_by": "pipe_points",
"style": {
"color": "#9b6bdc",
"abbr": "기슭"
@@ -987,8 +988,8 @@
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["좌", "우"],
"default": "",
"choices": ["양쪽", "좌", "우"],
"default": "양쪽",
"required": false,
"phase": "b05"
},
@@ -21,6 +21,7 @@ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_A
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { restrictToSide } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import {
@@ -223,13 +224,18 @@ export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
if (cached && cached.hash === hash) return cached.layout;
let layout: CulvertLayout | null = null;
try {
// 독립 기슭막이(관 숨김)는 유입측도 기슭막이 벽으로 강제하고(집수정 금지), 설치 측만
// 남긴다 — 횡단도(computeCardCulvert)와 같은 규칙으로 3D도 맞춘다(2026-08-28 이관).
layout = computeCulvertLayout(
section,
section.samples,
storedAdjusts(section),
section.design.inlet_structure ?? "auto",
section.culvert.hidden_pipe ? "revet" : (section.design.inlet_structure ?? "auto"),
section.design.basin_adjust,
);
if (layout && section.culvert.hidden_pipe) {
layout = restrictToSide(layout, section.culvert.side);
}
} catch {
layout = null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
}
@@ -275,9 +281,12 @@ export function buildCorridorStructures(
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
// 독립 기슭막이 — 횡단 카드와 **같은 폴리곤**을 그대로 스윕한다(2026-08-28 사용자).
// 조정창 조작값도 같이 읽는다 — 3D는 정본(design.revet_adjust)만 본다.
const revetLayout = computeRevetmentLayout(section, section.design?.revet_adjust?.own);
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
const revetLayout = section.culvert
? null
: computeRevetmentLayout(section, section.design?.revet_adjust?.own);
if (!layout && !fordLayout && !section.box && !revetLayout) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
@@ -521,10 +530,13 @@ export function buildCorridorStructures(
const inletSpan = wallSpanOf(section, "inlet");
const outletSpan = wallSpanOf(section, "outlet");
const basinSpan = basinSpanOf(section);
const hiddenPipe = section.culvert?.hidden_pipe === true;
const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet);
for (const wall of layout.walls) {
const span = wall.role === "inlet" ? inletSpan : outletSpan;
pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore);
// 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다.
if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM);
else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore);
}
// 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다.
for (const wall of layout.extraWalls) {
@@ -565,17 +577,20 @@ export function buildCorridorStructures(
}
}
solids.push({
chainage_m: chainage,
kind: "pipe",
pipe: {
start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation],
end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation],
diameterM: layout.culvert.diameter_m,
wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m),
},
frame: stationFrame,
});
// 독립 기슭막이는 관 실린더를 그리지 않는다(숨김).
if (!hiddenPipe) {
solids.push({
chainage_m: chainage,
kind: "pipe",
pipe: {
start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation],
end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation],
diameterM: layout.culvert.diameter_m,
wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m),
},
frame: stationFrame,
});
}
}
return solids;
}
@@ -227,8 +227,23 @@ export function createFacilityOptionsForm(
...outletRevet.rows,
);
// 독립 기슭막이(D4)는 서브폼을 떠났다 — C군과 같은 레지스트리 옵션 방식
// (측점 범위 + 기준측점 전/후, 2026-08-19 사용자 지시 3).
// ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ─────────────
// 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과
// 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다.
const revetGroup = group("기슭막이");
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetTiers = numberInput("1", "1");
revetTiers.value = "1";
const standaloneRevet = createRevetmentFields(
{ form: "form", length: "length_m", height: "height_m", before: "before_m", after: "after_m" },
{ ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)" },
);
revetGroup.body.append(
grid(labeled("설치 측", revetSide), standaloneRevet.formField),
...standaloneRevet.rows,
grid(labeled("단 수(다단)", revetTiers)),
);
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
@@ -329,6 +344,7 @@ export function createFacilityOptionsForm(
pipeRow,
inletGroup.root,
outletGroup.root,
revetGroup.root,
boxWrap,
wingInFields.root,
wingOutFields.root,
@@ -360,6 +376,7 @@ export function createFacilityOptionsForm(
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
revetGroup.root.hidden = current !== "revetment";
if (isFord) syncFordSummary();
if (isBox) syncBoxSize();
if (hasWing) {
@@ -393,6 +410,8 @@ export function createFacilityOptionsForm(
);
inletRevet.onChange(emit);
outletRevet.onChange(emit);
standaloneRevet.onChange(emit);
[revetSide, revetTiers].forEach((input) => input.addEventListener("change", emit));
[
pipeMaterial,
pipeDiameter,
@@ -451,6 +470,9 @@ export function createFacilityOptionsForm(
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
fordSlope.value = text("ford_slope_pct");
revetSide.value = text("side") || "양쪽";
revetTiers.value = text("tiers") || "1";
standaloneRevet.write(options);
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = String(
@@ -497,6 +519,11 @@ export function createFacilityOptionsForm(
putFordHeight(options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
const tiers = Number.parseInt(revetTiers.value, 10);
if (Number.isFinite(tiers) && tiers >= 1) options.tiers = tiers;
standaloneRevet.read(options);
}
return options;
},
@@ -240,4 +240,5 @@ export const FACILITY_NAMES: Record<PipeFacility, string> = {
box_culvert: "BOX암거",
ford_pavement: "물넘이포장",
ford_bridge: "세월교",
revetment: "기슭막이",
};
+6
View File
@@ -225,6 +225,12 @@ export interface CulvertSet {
min_cover_m: number;
inlet: CulvertSideSpec;
outlet: CulvertSideSpec;
/** 독립 기슭막이(관 없는 벽) — true면 관을 그리지 않고 수량에서도 뺀다(2026-08-28). */
hidden_pipe?: boolean;
/** 독립 기슭막이 설치 측 — "양쪽" | "좌" | "우". 좌/우면 반대쪽 벽을 숨긴다. */
side?: string;
/** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */
tiers?: number;
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
+50
View File
@@ -30,6 +30,7 @@ from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
parse_pipe_points,
)
from config.config_system import (
@@ -213,6 +214,51 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
}
def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
"""독립 기슭막이 한쪽 벽 제원 — 배관 유입/유출 벽과 같은 스펙 모양으로 만든다.
독립 기슭막이는 형태·높이·길이·전후를 한 벌만 가지므로 양쪽 벽에 같은 값을 싣는다.
실제로 어느 쪽(좌/우)을 세울지는 측점 방향을 아는 B06이 `side`로 가린다.
"""
height = _number(values.get("height_m"), None)
form = values.get("form")
spec: dict[str, Any] = {
"role": role,
"structure": "기슭막이",
"revet_form": str(form) if form else None,
"revet_height_m": height,
"revet_length_m": _number(values.get("length_m"), None),
"revet_before_m": _number(values.get("before_m"), None),
"revet_after_m": _number(values.get("after_m"), None),
"face_slope": REVET_FACE_SLOPE,
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""독립 기슭막이 1개소 세트 — 배관 세트와 같은 모양이되 **관을 숨긴다**(hidden_pipe).
관경은 표시·수량에서 빠지므로 토큰값(0.3)만 둔다 — 벽 높이는 형태(재질) 한계와
사용자 지정 높이가 정한다. `side`(양쪽/좌/우)로 어느 벽을 세울지 B06이 가리고,
`tiers`로 다단을 요청한다.
"""
values = dict(options or {})
return {
"type": "pipe",
"hidden_pipe": True,
"side": str(values.get("side") or "양쪽"),
"tiers": int(_number(values.get("tiers"), 1.0) or 1),
"pipe_kind": None,
"diameter_m": 0.3,
"min_cover_m": 0.0,
"inlet": _revet_side(values, "inlet"),
"outlet": _revet_side(values, "outlet"),
}
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
@@ -344,6 +390,10 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
elif point.facility == PIPE_FACILITY_FORD_PAVEMENT:
# 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28).
spec = _ford_pavement_set(point.options)
elif point.facility == PIPE_FACILITY_REVET:
# 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관
# 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다.
spec = _revet_set(point.options)
else:
continue
sets[round(float(point.chainage_m), 2)] = spec
+1 -4
View File
@@ -21,7 +21,6 @@ from B05_Profile.B05_Profile_Engine_Sections import (
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
from B06_Section.B06_Section_Engine_Revetment import attach_revetments
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Repository import (
count_cross_sections,
@@ -253,9 +252,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
):
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
attach_culvert_sets(root, cross_sections)
# 배관과 무관한 **독립 기슭막이**(구조물 정본 D군)는 구간 안 측점 전부에 얹는다.
attach_revetments(root, cross_sections)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -477,7 +475,6 @@ async def regenerate_sections(
result = sections["result"]
# 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
attach_revetments(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
@@ -172,8 +172,11 @@ export function computeCulvertLayout(
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const material = adjust.m ?? materialFromForm(spec.revet_form);
const limit = materialLimit(material);
const floor = pipeWallMinPureHeight(diameter);
const pureHeight = Math.min(Math.max(adjust.h ?? floor, floor), Math.max(limit, floor));
// 독립 기슭막이(관 숨김)는 관경이 없다 — 하한은 근입만, **기본 높이 = 사용자가 고른
// 형태 높이**(revet_height_m). 배관은 관경 기준 최소 높이(관이 벽 밖으로 안 삐져나오게).
const floor = culvert.hidden_pipe ? REVET_EMBED_DEPTH_M : pipeWallMinPureHeight(diameter);
const preferred = culvert.hidden_pipe ? (spec.revet_height_m ?? floor) : floor;
const pureHeight = Math.min(Math.max(adjust.h ?? preferred, floor), Math.max(limit, floor));
return { material, limit, pureHeight, height: pureHeight - REVET_EMBED_DEPTH_M };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
@@ -219,18 +222,22 @@ export function computeCulvertLayout(
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
edgeElevation: inletInfo.edge.elevation_m,
invertCap: invertCap(inletInfo.edge),
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게
// 오르내린다(관이 없어 유출 수용 검사 outletGuard도 뺀다 — 위 이동을 막던 원인).
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
adjust: adjInlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: inletInfo.limit,
outletGuard: {
edge: outletInfo.edge,
outward: outletInfo.outward,
height: outletWallSpec.height,
toeOffset: slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit),
limitOffset: outletInfo.limit,
},
outletGuard: culvert.hidden_pipe
? undefined
: {
edge: outletInfo.edge,
outward: outletInfo.outward,
height: outletWallSpec.height,
toeOffset: slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit),
limitOffset: outletInfo.limit,
},
});
appliedAdjust.inlet.x = placed.x;
appliedAdjust.inlet.d = placed.d;
@@ -425,7 +432,8 @@ export function computeCulvertLayout(
height: outletWallSpec.height,
baseElevation0: outletZeroInvert,
edgeElevation: outletInfo.edge.elevation_m,
invertCap: inlet.elevation,
// 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
adjust: adjOutlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
@@ -264,6 +264,31 @@ function trimLinkedLayout(layout: CulvertLayout, link: CulvertLink): CulvertLayo
};
}
/**
* ( ) ** **(//) ·
* . (+offset=)=trimMax ,
* =trimMin . ··· ( ).
*/
export function restrictToSide(layout: CulvertLayout, side: string | undefined): CulvertLayout {
if (side !== "좌" && side !== "우") return layout; // 양쪽·미지정 → 둘 다
const inletIsMax = layout.pipe.inlet.offset > layout.pipe.outlet.offset;
const keepMax = side === "좌";
const keepMin = side === "우";
const keepInlet = inletIsMax ? keepMax : keepMin;
const keepOutlet = inletIsMax ? keepMin : keepMax;
const empty = { segments: [], addable: false };
return {
...layout,
walls: layout.walls.filter((wall) => (wall.role === "inlet" ? keepInlet : keepOutlet)),
extraWalls: keepOutlet ? layout.extraWalls : [],
outletFill: keepOutlet ? layout.outletFill : empty,
basinExtras: keepInlet ? layout.basinExtras : [],
basinFill: keepInlet ? layout.basinFill : empty,
basin: keepInlet ? layout.basin : null,
designTrim: keepTrimSides(layout.designTrim, keepMin, keepMax),
};
}
export function computeCardCulvert(
section: CrossSection,
sourceSamples: SectionSample[],
@@ -295,14 +320,14 @@ export function computeCardCulvert(
link.source,
link.source.samples,
adjustsInput(link.source, revetOffset, extraWalls),
inletStructure?.valueFor(link.source),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
const layout = computeCulvertLayout(
hosted,
sourceSamples,
ownerAdjusts,
inletStructure?.valueFor(link.source),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(detached ? section : link.source),
);
if (!layout) return null;
@@ -310,14 +335,19 @@ export function computeCardCulvert(
// 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서
// 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적).
// 3D 스윕에서만 프레임 dz를 0으로 눕힌다(B05_Profile_UI_Corridor_Structures).
return trimLinkedLayout(layout, link);
const linked = trimLinkedLayout(layout, link);
return link.source.culvert?.hidden_pipe
? restrictToSide(linked, link.source.culvert.side)
: linked;
}
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
// 독립 기슭막이(관 숨김)는 유입측도 **집수정이 아니라 기슭막이 벽**으로 강제한다 —
// 관이 없어 집수정 자동 전환이 의미 없다(설치 측은 아래 restrictToSide가 가린다).
const layout = computeCulvertLayout(
section,
sourceSamples,
adjustsInput(section, revetOffset, extraWalls),
inletStructure?.valueFor(section),
section.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(section),
inletStructure?.adjustFor(section),
equalizeExtras,
);
@@ -411,7 +441,8 @@ export function computeCardCulvert(
});
}
}
return layout;
// 독립 기슭막이는 사용자가 고른 설치 측(양쪽/좌/우)만 남긴다.
return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout;
}
function adjustsInput(
+177 -187
View File
@@ -1,22 +1,44 @@
/* =============================================================================
* B06_Section_UI_Cross_Revetment.ts
* ( ) + (2026-08-28 ).
* ( ) ** · · **.
* , ·· (2026-08-28 :
* "배관용에서 배관만 빼면 대부분 동일해야").
*
* : ** ( )**.
* + 0.5m만큼 . ( ) 1:0.3으로
* ( , 7-3). ** / ** .
* · : `placePipeWall`( 4 ) d=0 , d로
* 1:1.2 . base(=invert) ** ** ( ),
* `floatGap` . x·d ( · ) .
* · ( ) `appliedAdjust` ,
* ( syncApplied ).
* · 다단: 배관과 ** ** `buildExtrasAt`. (···)
* `_Cross_Wall`. designTrim의 slope로 .
*
* 3D (`B05_Profile_UI_Corridor_Structures`)
* .
* 3D `tiers[].polygon`·`span` .
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import {
fillWallBaseWidth,
groundInterpolator,
placePipeWall,
} from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import {
ZERO_ADJUST,
type CulvertDesignTrim,
type OutletFillSegment,
type WallAdjust,
type WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -34,9 +56,8 @@ export interface RevetmentSpec {
side?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** 1단(기준·맨 위) 벽을 성토면 끝에서 도로 쪽으로 올린 사면 거리(m). 0이면 성토면 끝. */
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
/** 기준 벽 좌우 이동(m) — + = 계류 쪽(바깥). 0이면 이동 없음. */
shift_m?: number | null;
}
@@ -52,199 +73,142 @@ export interface RevetPoint {
elevation: number;
}
/** 한 단(段) — 벽 하나. 1단이 기준(맨 위)이고 추가 단은 아래로 붙는다. */
/** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */
export interface RevetmentTier {
/** 벽 상단 자리(배면 = 도로측). 1단은 성토면 끝. */
top: RevetPoint;
/** 벽 하단 표고(근입 포함). */
bottomElevation: number;
/** 단면 폴리곤(도로측 수직 배면 → 상단 → 기운 전면 → 바닥). */
polygon: RevetPoint[];
}
export interface RevetmentLayout {
side: "left" | "right";
/** 실제로 쓴 벽 높이(m) — 조정창 높이 표시·조작의 기준값. */
/** 1단 벽 높이(m) — 조정창 높이 표시·조작의 기준값. */
heightM: number;
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 세는 데 쓴다(재렌더 중복 방지). */
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 다(재렌더 중복 방지). */
chainageM: number;
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`tiers.length`)와 다르면 자리가 부족한 것이다. */
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */
requestedTiers: number;
/** 1단 벽 상단 = 성토면 끝(설계선-지반 교차점). */
/** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */
top: RevetPoint;
/** 목록 — 사용자가 지정한 단 수만큼, 벽이 지반에 묻히면 거기서 끝난다. */
/** 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */
walls: WallLayout[];
/** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */
fillSegments: OutletFillSegment[];
/** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */
designTrim: CulvertDesignTrim;
/** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */
appliedAdjust: WallAdjust;
/** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
}
/** 지반 표고 보간 — 샘플 사이는 선형. 유효 표본이 없으면 null. */
function groundElevationAt(section: CrossSection, offsetM: number): number | null {
const points = section.samples
.filter(
(sample) =>
sample.valid !== false &&
typeof sample.offset_m === "number" &&
typeof sample.elevation_m === "number",
)
.map((sample) => ({
offset: sample.offset_m as number,
elevation: sample.elevation_m as number,
}))
.sort((a, b) => a.offset - b.offset);
const first = points[0];
const last = points[points.length - 1];
if (!first || !last || points.length < 2) return null;
if (offsetM <= first.offset) return first.elevation;
if (offsetM >= last.offset) return last.elevation;
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1];
const b = points[i];
if (!a || !b) continue;
if (offsetM <= b.offset) {
const t = (offsetM - a.offset) / (b.offset - a.offset || 1);
return a.elevation + (b.elevation - a.elevation) * t;
}
}
return null;
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */
function materialOfForm(form?: string | null): RevetMaterial {
if (!form) return "dry";
if (form.includes("찰")) return "wet";
if (form.includes("콘크리트")) return "concrete";
return "dry"; // 메쌓기·돌망태·통나무·바자 — 메 계열(2.0m 한계)
}
/** 유효 지반 샘플의 바깥 한계 offset(설치 측). 없으면 null. */
function sampleLimit(section: CrossSection, outward: number): number | null {
const offsets = section.samples
.filter((s) => s.valid !== false && typeof s.offset_m === "number")
.map((s) => s.offset_m as number);
if (!offsets.length) return null;
return outward > 0 ? Math.max(...offsets) : Math.min(...offsets);
}
/**
* .
* ( ) .
*/
function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
const outward = side === "left" ? 1 : -1;
const edge = design.road_edges[side].offset_m;
const beyond = line
.filter((point) => (point.offset_m - edge) * outward > 1e-9)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
let previous: { offset: number; gap: number } | null = null;
for (const point of beyond) {
const ground = groundElevationAt(section, point.offset_m);
if (ground === null) continue;
const gap = point.elevation_m - ground;
if (previous && previous.gap > 0 && gap <= 0) {
const t = previous.gap / (previous.gap - gap || 1);
const offset = previous.offset + (point.offset_m - previous.offset) * t;
const elevation = groundElevationAt(section, offset);
if (elevation !== null) return { offset, elevation };
}
previous = { offset: point.offset_m, gap };
}
const tail = beyond[beyond.length - 1];
if (!tail) return null;
return { offset: tail.offset_m, elevation: tail.elevation_m };
}
/**
* ** ** .
* , (toe)
* ( 2026-08-28 ).
*/
function tierAnchors(
anchor: RevetPoint,
toe: RevetPoint,
height: number,
count: number,
): RevetPoint[] {
const anchors: RevetPoint[] = [anchor];
const drop = anchor.elevation - toe.elevation;
const run = toe.offset - anchor.offset;
if (!(drop > 1e-6) || !(height > 0)) return anchors;
for (let index = 1; index < count; index += 1) {
const fall = height * index;
if (fall > drop + 1e-6) break; // 성토면 끝을 지난다 — 자리 없음
anchors.push({
offset: anchor.offset + (run * fall) / drop,
elevation: anchor.elevation - fall,
});
}
return anchors;
}
function anchorAt(
section: CrossSection,
side: "left" | "right",
toe: RevetPoint,
liftM: number,
shiftM: number,
nudgeM: number,
): RevetPoint {
let anchor = toe;
const edge = section.design?.road_edges?.[side];
if (edge && liftM > 0) {
const run = edge.offset_m - toe.offset;
const rise = edge.elevation_m - toe.elevation;
const length = Math.hypot(run, rise);
if (length > 1e-6) {
const t = Math.min(liftM / length, 1);
anchor = { offset: toe.offset + run * t, elevation: toe.elevation + rise * t };
}
}
const outward = side === "left" ? 1 : -1;
const lateral = outward * shiftM + nudgeM;
if (!lateral) return anchor;
// 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게).
const offset = anchor.offset + lateral;
const ground = groundElevationAt(section, offset);
return { offset, elevation: ground ?? anchor.elevation };
}
/**
* . · null.
*
* ** **(2026-08-22 , 2026-08-28 ):
* · (1) , **** .
* · = +(0.5m) (1:0.3) .
* · ( ) "자리가 없다"
* , (`lift_m`) .
* · 1:1.2.
* ·· null.
* 1 = `placePipeWall`+`buildRevetWallGeometry`,
* = `buildExtrasAt`.
*/
export function computeRevetmentLayout(
section: CrossSection,
adjust?: RevetmentAdjust | null,
): RevetmentLayout | null {
const spec = section.revetment;
// 높이는 조정창 값이 있으면 그것이 우선한다(배관 벽과 같은 규칙).
const height = Number(adjust?.h ?? spec?.height_m);
if (!spec || !Number.isFinite(height) || height <= 0) return null;
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
const toe = fillToeAt(section, side);
if (!toe) return null;
const design = section.design;
if (!spec || !design) return null;
const requestedHeight = Number(adjust?.h ?? spec.height_m);
if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null;
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
const outward = side === "left" ? 1 : -1;
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
// 조정창 ▲▼(d)는 **사면 아래가 +** — 기준 올림과 반대 방향이다.
const liftM = (Number(spec.lift_m) || 0) - (adjust?.d ?? 0);
// 조정창 ◀▶(x)는 편거리 증가(+ = 화면 왼쪽)로 들어온다.
const anchor = anchorAt(section, side, toe, liftM, Number(spec.shift_m) || 0, adjust?.x ?? 0);
const tiers: RevetmentTier[] = tierAnchors(anchor, toe, height, requestedTiers).map((top) => {
const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M;
const frontTop = top.offset + outward * REVET_THICKNESS_M;
const frontBottom = frontTop + outward * REVET_LEAN_RATIO * (top.elevation - bottomElevation);
return {
top,
bottomElevation,
polygon: [
{ offset: top.offset, elevation: top.elevation },
{ offset: frontTop, elevation: top.elevation },
{ offset: frontBottom, elevation: bottomElevation },
{ offset: top.offset, elevation: bottomElevation },
],
};
const edge = design.road_edges?.[side];
const groundAt = groundInterpolator(section.samples);
const limit = sampleLimit(section, outward);
if (!edge || !groundAt || limit === null) return null;
// 재질 한계로 높이를 자른다(배관 벽과 같은 규칙).
const height = Math.min(requestedHeight, revetHeightLimit(spec.form));
// 배관 유출 벽과 **같은 4축 배치**. 기준(d=0) = 벽 상단이 노견인 자리.
const autoOffset = edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
const placed = placePipeWall({
autoOffset,
outward,
height,
baseElevation0: edge.elevation_m - height, // d=0에서 벽 상단 = 노견
edgeElevation: edge.elevation_m,
invertCap: Number.POSITIVE_INFINITY, // 관 없음 — 위 한계는 d≥0(노견)만
adjust: { x: adjust?.x ?? 0, d: adjust?.d ?? null, h: null, m: null },
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: limit,
requireCrossing: true, // 유출 벽처럼 매몰(원지반 아래 daylight 없음) 금지
});
const baseElevation = placed.invert;
const floatGapM = Math.max(0, baseElevation - groundAt(placed.anchorOffset));
const material = materialOfForm(spec.form);
const lengthM = Number.isFinite(spec.end_m - spec.start_m) ? spec.end_m - spec.start_m : null;
const tier1 = buildRevetWallGeometry({
anchor: { offset: placed.anchorOffset, elevation: baseElevation },
outward,
height,
material,
role: "outlet",
form: spec.form,
lengthM,
floatGapM,
});
// 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로.
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
const extras =
requestedTiers > 1
? buildExtrasAt(tier1.bottomFront, {
startBottomElevation: tier1.bottomBack.elevation,
outward,
groundAt,
limitOffset: limit,
adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })),
equalize: false,
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
const walls: WallLayout[] = [tier1, ...extras.walls];
const jt = tier1.topJoint;
// 노견 → 벽 이음선 상단점 성토선(1:1.2, 벽이 밖으로 밀리면 그만큼 노견이 연장된다).
const slope = { points: [{ offset: edge.offset_m, elevation: edge.elevation_m }, jt] };
const designTrim: CulvertDesignTrim =
outward > 0
? { minOffset: -Infinity, maxOffset: jt.offset, maxElevation: jt.elevation, maxSlope: slope }
: { minOffset: jt.offset, maxOffset: Infinity, minElevation: jt.elevation, minSlope: slope };
return {
side,
heightM: height,
chainageM: Number(section.chainage_m) || 0,
requestedTiers,
top: tiers[0]?.top ?? anchor,
tiers,
top: jt,
walls,
fillSegments: extras.segments,
designTrim,
appliedAdjust: { x: placed.x, d: placed.d, h: adjust?.h ?? null, m: null },
tiers: walls.map((wall) => ({ polygon: wall.points })),
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
@@ -258,15 +222,14 @@ let shortfallTimer = 0;
let shortfallLast = "";
function noticeShortfall(layout: RevetmentLayout): void {
if (layout.tiers.length >= layout.requestedTiers) return;
// 같은 측점이 다시 그려져도 한 번만 센다 — 카드는 조작할 때마다 다시 그린다.
if (layout.walls.length >= layout.requestedTiers) return;
shortfallStations.add(Math.round(layout.chainageM * 100));
window.clearTimeout(shortfallTimer);
shortfallTimer = window.setTimeout(() => {
const message =
`기슭막이 ${layout.requestedTiers}단 중 ${layout.tiers.length}단만 세울 수 있습니다` +
`기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` +
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
"기준 올림(사면 위로)으로 1단을 올린 뒤 단을 늘리세요.";
"기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요.";
shortfallStations.clear();
if (message === shortfallLast) return;
shortfallLast = message;
@@ -274,7 +237,7 @@ function noticeShortfall(layout: RevetmentLayout): void {
}, 400);
}
/** 횡단 카드에 벽 단면을 그린다(다단이면 단마다 하나). 그렸으면 true. */
/** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */
export function appendRevetmentOverlay(
svg: SVGElement,
layout: RevetmentLayout | null,
@@ -282,26 +245,53 @@ export function appendRevetmentOverlay(
y: (elevation: number) => number,
onSelect?: () => void,
): ((active: boolean) => void) | null {
if (!layout || !layout.tiers.length) return null;
if (!layout || !layout.walls.length) return null;
noticeShortfall(layout);
const drawn: SVGPolygonElement[] = [];
for (const tier of layout.tiers) {
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
tier.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "),
// 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다.
for (const segment of layout.fillSegments) {
if (segment.kind === "cut") continue;
appendPlanLine(
svg,
segment.points,
x,
y,
`독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
polygon.setAttribute("class", "b06-chart__revetment");
if (onSelect) {
// 배관 기슭막이와 같은 조작 — 벽을 누르면 조정창이 열린다(2026-08-28 사용자).
polygon.classList.add("is-selectable");
polygon.addEventListener("click", (event) => {
}
const drawn: SVGPolygonElement[] = [];
layout.walls.forEach((wall, index) => {
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도`
: ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "");
const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId);
if (onSelect) shape.classList.add("is-selectable");
drawn.push(shape);
});
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
// 달면 가운데 클릭이 돌에 먹힌다(배관 벽과 같은 규칙 — 판정면만 위로).
if (onSelect) {
for (const wall of layout.walls) {
const hit = document.createElementNS(SVG_NS, "polygon");
hit.setAttribute(
"points",
wall.points.map((p) => `${x(p.offset)},${y(p.elevation)}`).join(" "),
);
hit.setAttribute("class", "b06-chart__culvert-revet-hit");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelect();
});
svg.append(hit);
}
svg.append(polygon);
drawn.push(polygon);
}
return (active: boolean): void => {
for (const polygon of drawn) polygon.classList.toggle("is-active", active);
+52 -31
View File
@@ -26,6 +26,7 @@ import {
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert";
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
@@ -442,27 +443,38 @@ export function createCrossSectionCard(
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
// 독립 기슭막이 — 성토면 끝에 서는 벽. 3D도 같은 폴리곤을 스윕한다.
// 조작값(4축)은 배관 벽과 같은 저장소(design.revet_adjust["own"])를 쓴다.
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
const setOwnActive = appendRevetmentOverlay(
plotLayer,
ownLayout,
x,
toDisplayY,
revetOffset ? () => toggleRevet("own") : undefined,
);
if (ownLayout && setOwnActive) {
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
culvertWallSpecs.set("own", { height: ownLayout.heightM, material: "dry" });
culvertAppliedD.set("own", ownAdjust?.d ?? 0);
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
baseSetActive(key);
setOwnActive(key === "own");
};
if (activeRevet === "own") setOwnActive(true);
// 독립 기슭막이(구 D군 경로) — 이 측점에 배관/숨김 기슭막이 세트(section.culvert)가
// 붙으면 그쪽(배관 경로)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 그린다
// (2026-08-28 이관: pipe_points 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드).
let ownDesignTrim: CulvertDesignTrim | undefined;
// 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다 — 2026-08-28 이관 이중그리기 방지).
if (!section.culvert && !culvertLink) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
ownDesignTrim = ownLayout?.designTrim;
const setOwnActive = appendRevetmentOverlay(
plotLayer,
ownLayout,
x,
toDisplayY,
revetOffset ? () => toggleRevet("own") : undefined,
);
if (ownLayout && setOwnActive) {
// 한계에 잘린 실제 적용값을 조작 저장소에 되받는다(배관 벽과 같은 규칙) —
// 눌러도 안 움직이는데 숫자만 커지는 것 방지.
revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust);
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
culvertWallSpecs.set("own", { height: ownLayout.heightM, material: "dry" });
culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
baseSetActive(key);
setOwnActive(key === "own");
};
if (activeRevet === "own") setOwnActive(true);
}
}
appendCrossDesignOverlay(
plotLayer,
@@ -470,7 +482,10 @@ export function createCrossSectionCard(
x,
toDisplayY,
drawSamples,
culvertLayout?.designTrim ?? fordLayout?.designTrim ?? boxLayout?.designTrim,
culvertLayout?.designTrim ??
fordLayout?.designTrim ??
boxLayout?.designTrim ??
ownDesignTrim,
);
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") {
@@ -484,7 +499,11 @@ export function createCrossSectionCard(
}
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점
// 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24).
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
// 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김.
culvertPipeLengthM =
isLinkedCulvert || culvertLayout?.culvert.hidden_pipe
? null
: (culvertLayout?.pipe.lengthM ?? null);
culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) {
const state = culvertCardState(culvertLayout);
@@ -519,7 +538,8 @@ export function createCrossSectionCard(
x,
toDisplayY,
revetOffset ? toggleRevet : undefined,
isLinkedCulvert,
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
);
if (activeRevet) setRevetActive(activeRevet);
}
@@ -571,12 +591,6 @@ export function createCrossSectionCard(
y2: heightPx - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgText(L("B06_Profile_View_CrossXAxis"), {
x: widthPx / 2,
y: heightPx - 8,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 13,
y: heightPx / 2,
@@ -620,7 +634,14 @@ export function createCrossSectionCard(
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
// 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다.
role === "own"
? section.revetment?.side === "우"
? -1
: 1
: ((section.uphill_side ?? "left") === "left") === (role === "inlet")
? 1
: -1;
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
const materialOfWall = (key: RevetKey): RevetMaterial =>
culvertWallSpecs.get(key)?.material ?? "dry";
+209
View File
@@ -0,0 +1,209 @@
/* =============================================================================
* B06_Section_UI_Cross_Wall.ts
* ** + ** (`_Cross_Culvert_Geom`/`_Culvert`)
* (`_Cross_Revetment`) ·
* (2026-08-28 : "배관용에서 배관만 빼면 대부분 동일해야"). 1
* ( + , · 1:0.3) .
*
* `_Cross_Culvert_Geom`
* ( ).
* · .
* ========================================================================== */
import {
materialLabel,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
const SVG_NS = "http://www.w3.org/2000/svg";
function polygon(
points: Array<[number, number]>,
className: string,
tooltip: string,
): SVGPolygonElement {
const shape = document.createElementNS(SVG_NS, "polygon");
shape.setAttribute("points", points.map(([px, py]) => `${px},${py}`).join(" "));
shape.setAttribute("class", className);
if (tooltip) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
shape.append(title);
}
return shape;
}
/** 1 ** **(anchor.offset)
* (anchor.elevation = invert ). `height` . */
export interface RevetWallGeometryInput {
/** 하단선 중점 offset + 기준표고(벽이 이 위로 선다). */
anchor: OffsetPoint;
/** +1 = 화면 좌측(계류측), 1 = 우측. 전면 1:0.3이 이 부호로 기운다. */
outward: number;
/** 계산용 높이(기준표고~상단, 근입 0.5 제외). */
height: number;
material: RevetMaterial;
role: "inlet" | "outlet" | "extra";
/** 표기용 형태 — 미지정이면 재질 라벨(메/찰/콘크리트). */
form?: string | null;
lengthM?: number | null;
thickness?: number;
floatGapM?: number;
extraIndex?: number;
}
/**
* ( + ) . (`buildWall`,
* `_Cross_Culvert_Geom` 335~376) ** ** , =
* (t/2)+(t), 1:0.3, = 0.5m .
*/
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, height, material, role } = input;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
elevation: bottomElevation,
};
return {
role,
extraIndex: input.extraIndex,
form: input.form ?? materialLabel(material),
lengthM: input.lengthM ?? null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
height,
floatGapM: input.floatGapM ?? 0,
material,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
}
/**
* 1 SVG로 + + .
* (`_Cross_Culvert` 161~246) ** **. ·
* . `keyId` clipPath id .
*/
export function drawRevetWall(
layer: SVGElement,
wall: WallLayout,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
className: string,
tooltip: string,
keyId: string,
): SVGPolygonElement {
const revetShape = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
className,
tooltip,
);
layer.append(revetShape);
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계) — 상단 변 중간점에서 바닥으로.
const joint = document.createElementNS(SVG_NS, "line");
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
const bottomAtOffset = (offset: number): number =>
Math.abs(bottomSpan) > 1e-9
? wall.bottomBack.elevation +
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
joint.setAttribute("x1", String(x(wall.topJoint.offset)));
joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation)));
joint.setAttribute("x2", String(x(jointBaseOffset)));
joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset))));
joint.setAttribute("class", "b06-chart__culvert-stone");
layer.append(joint);
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 계류측 기운 띠. 벽 폴리곤 clip 안에만 그려 어떤
// 높이에서도 돌이 벽 밖으로 새지 않게 한다.
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
clip.append(
polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"",
"",
),
);
const stoneGroup = document.createElementNS(SVG_NS, "g");
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
layer.append(clip, stoneGroup);
const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const embedBase = bottomAtOffset(centerBaseOffset);
const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase);
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
const stoneHeight = stoneSpan / stoneCount;
const centerBase = centerBaseOffset;
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
const axisX = x(centerTop) - x(centerBase);
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI;
for (let i = 0; i < stoneCount; i += 1) {
const fraction = (i + 0.5) / stoneCount;
const centerOffset = centerBase + (centerTop - centerBase) * fraction;
const centerElevation = embedBase + stoneSpan * fraction;
const cx = x(centerOffset);
const cy = toDisplayY(centerElevation);
const stone = document.createElementNS(SVG_NS, "rect");
const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9;
const heightPx = stoneHeight * pixelsPerMeter * 0.86;
stone.setAttribute("x", String(cx - widthPx / 2));
stone.setAttribute("y", String(cy - heightPx / 2));
stone.setAttribute("width", String(widthPx));
stone.setAttribute("height", String(heightPx));
stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3));
stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`);
stone.setAttribute("class", "b06-chart__culvert-stone");
stoneGroup.append(stone);
}
return revetShape;
}
/** 성토·절토 접속선(공사 계획선) 폴리라인 — 설계선과 같은 보라 실선. */
export function appendPlanLine(
layer: SVGElement,
points: OffsetPoint[],
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
tooltip: string,
className = "b06-chart__design-cross",
): void {
if (points.length < 2) return;
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute(
"points",
points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
);
line.setAttribute("class", className);
if (tooltip) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
line.append(title);
}
layer.append(line);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const CROSS_GRID_GAP = 16;
// (2026-08-05 사용자 보고). sticky Y축 마스크·테이블 행제목 폭이 모두 이 값에서
// 파생되므로 함께 넓어져 가로 스크롤 시 값 누출이 없다.
export const LONG_PAD = { left: 78, right: 24, top: 12, bottom: 26 };
export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 26 };
export interface YScaleOptions {
pixelsPerMeter: number;
@@ -44,11 +44,15 @@ PIPE_FACILITY_PIPE = "pipe" # 배관(횡단배수관) — 기본
PIPE_FACILITY_BOX = "box_culvert" # BOX암거
PIPE_FACILITY_FORD_PAVEMENT = "ford_pavement" # 물넘이포장
PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교
# 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를
# 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다.
PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김)
_KNOWN_FACILITIES = (
PIPE_FACILITY_PIPE,
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_REVET,
)
@@ -300,6 +304,9 @@ def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float
if facility == PIPE_FACILITY_FORD_PAVEMENT:
# 물넘이포장은 도로 위에 그대로 만든다 — 들어 올릴 이유가 없다.
return 0.0
if facility == PIPE_FACILITY_REVET:
# 독립 기슭막이는 성토 사면에 세우는 벽 — 관이 없어 들어 올릴 여유가 필요 없다.
return 0.0
diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0
extra = FORD_BRIDGE_EXTRA_M if facility == PIPE_FACILITY_FORD_BRIDGE else 0.0
return diameter_m + MIN_PIPE_COVER_M + extra
-1
View File
@@ -316,7 +316,6 @@ export const ui_locales_b2 = {
"BP 기준 누적거리 (횡단 측점)",
"Chainage from BP (cross stations)",
],
B06_Profile_View_CrossXAxis: ["중심선 기준 편거리 (m)", "Offset from centerline (m)"],
B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"],
B06_Profile_View_CenterElevation: ["중심고", "Center elevation"],
B06_Profile_View_Azimuth: ["방위각", "Azimuth"],