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:
@@ -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 {
|
||||
|
||||
@@ -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군 노출형 횡단수로·개거): 기준 측점 한 곳.
|
||||
|
||||
@@ -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: "기슭막이",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user