Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Revetment.ts
|
||
* 독립 기슭막이(배관과 무관) — 배관 세트 기슭막이와 **같은 배치·같은 산식·같은 도형**.
|
||
* 배관만 없을 뿐, 벽 자리·이동·한계는 배관 유출 벽과 한 몸으로 푼다(2026-08-28 사용자:
|
||
* "배관용에서 배관만 빼면 대부분 동일해야").
|
||
*
|
||
* · 자리(2026-08-29 사용자 개정): 조작은 **순수 좌우(x)·순수 상하(d)** 0.1m다.
|
||
* x = 자동 자리에서의 수평 이동, d = 노견에서 잰 수직 하강. 성토선 각도는 유지하고
|
||
* **노견 확장 구간**이 길이를 흡수한다. 한계는 노견 안쪽 금지·확장 0(각도 유지)·매몰 금지.
|
||
* 배관 기준벽(`placePipeWall`)의 이동은 손대지 않는다.
|
||
* · 실제 적용값(한계 절삭 후)은 `appliedAdjust`로 돌려주고, 화면이 조작 저장소에 되받는다
|
||
* — 눌러도 안 움직이는데 숫자만 커지는 것 방지(배관과 같은 syncApplied 규칙).
|
||
* · 다단: 배관과 **같은 함수** `buildExtrasAt`. 그리기(폴리곤·이음선·돌·성토부선)는 공용
|
||
* `_Cross_Wall`. 노견→벽 성토선은 designTrim의 slope로 그린다.
|
||
*
|
||
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
|
||
* ========================================================================== */
|
||
|
||
import { showToast } from "@ui/ui_template_elements";
|
||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||
import {
|
||
FILL_SLOPE_RATIO_MIN,
|
||
PIPE_CONNECT_GRADE,
|
||
PIPE_WALL_DEFAULT_RUN_M,
|
||
REVET_EMBED_DEPTH_M,
|
||
REVET_THICKNESS_M,
|
||
revetHeightLimit,
|
||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import { leanOf } from "./B06_Section_UI_Cross_Lean";
|
||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||
import { buildExtrasAt, slopedCrossing } 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 { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
|
||
import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall";
|
||
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
|
||
/** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */
|
||
export interface RevetmentSpec {
|
||
structure_id: string | null;
|
||
type_id: string;
|
||
name: string;
|
||
start_m: number;
|
||
end_m: number;
|
||
anchor_m: number;
|
||
form?: string | null;
|
||
height_m?: number | null;
|
||
/** 사용자가 고른 설치 측 — "좌" | "우". */
|
||
side?: string | null;
|
||
/** 기초 축 — "기초유" | "기초버림". **비어 있으면 터파기를 안 그린다**(근거 없음). */
|
||
foundation?: string | null;
|
||
/** 단 수(다단). 1이면 단일 벽. */
|
||
tiers?: number | null;
|
||
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
|
||
lift_m?: number | null;
|
||
shift_m?: number | null;
|
||
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
|
||
face_role?: string | null;
|
||
face_slope_ratio?: number | string | null;
|
||
}
|
||
|
||
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
|
||
const TABLE_FORM_BY_TYPE: Record<string, string> = {
|
||
masonry_wet: "돌쌓기(찰)",
|
||
masonry_dry: "돌쌓기(메)",
|
||
};
|
||
|
||
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
|
||
export interface RevetmentAdjust {
|
||
x?: number;
|
||
d?: number | null;
|
||
h?: number | null;
|
||
}
|
||
|
||
export interface RevetPoint {
|
||
offset: number;
|
||
elevation: number;
|
||
}
|
||
|
||
/** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */
|
||
export interface RevetmentTier {
|
||
polygon: RevetPoint[];
|
||
}
|
||
|
||
export interface RevetmentLayout {
|
||
side: "left" | "right";
|
||
/** 1단 벽 높이(m) — **바닥~상단**. 조정창 높이 표시·조작의 기준값. */
|
||
heightM: number;
|
||
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */
|
||
chainageM: number;
|
||
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */
|
||
requestedTiers: number;
|
||
/** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */
|
||
top: RevetPoint;
|
||
/** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */
|
||
walls: WallLayout[];
|
||
/** 기초 축 — 저장 칸과 **같은 글자**(`foundation`). 터파기 그림이 이 값으로 갈린다. */
|
||
foundation: string | null;
|
||
/** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */
|
||
fillSegments: OutletFillSegment[];
|
||
/** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */
|
||
designTrim: CulvertDesignTrim;
|
||
/** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */
|
||
appliedAdjust: WallAdjust;
|
||
/** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */
|
||
tiers: RevetmentTier[];
|
||
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
|
||
span: { beforeM: number; afterM: number };
|
||
}
|
||
|
||
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 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);
|
||
}
|
||
|
||
/**
|
||
* 노면 횡단물매를 **바깥 방향 1m당 표고 변화**로 돌려준다(하향이면 음수).
|
||
* 노면은 측구 쪽으로 내려가는 단일 평면이라(엔진 `road_z`), 노견을 밖으로 연장하면
|
||
* 그 평면을 그대로 타고 가야 각도가 맞는다(2026-08-29 사용자).
|
||
* 1순위는 **실제 그려진 노견 구간**(차도 끝 → 노면 끝)의 기울기 — 부호를 추측하지
|
||
* 않는다. 노견 폭이 0이면 `cross_slope_pct`를 측구 방향 부호로 쓴다.
|
||
*/
|
||
function roadSlopePerOutward(
|
||
design: NonNullable<CrossSection["design"]>,
|
||
side: "left" | "right",
|
||
outward: number,
|
||
): number {
|
||
const edge = design.road_edges?.[side];
|
||
const inner = design.carriageway_edges?.[side];
|
||
if (edge && inner) {
|
||
const run = (edge.offset_m - inner.offset_m) * outward;
|
||
if (run > 1e-6) return (edge.elevation_m - inner.elevation_m) / run;
|
||
}
|
||
const pct = Number(design.cross_slope_pct);
|
||
if (!Number.isFinite(pct)) return 0;
|
||
return (pct / 100) * (design.ditch_side === side ? -1 : 1);
|
||
}
|
||
|
||
/**
|
||
* 독립 기슭막이 단면 — 제원·높이·설계선이 없거나 자리를 못 찾으면 null.
|
||
* 1단 벽 = 배관 유출 벽과 같은 `placePipeWall`+`buildRevetWallGeometry`,
|
||
* 다단 = 배관과 같은 `buildExtrasAt`.
|
||
*/
|
||
export function computeRevetmentLayout(
|
||
section: CrossSection,
|
||
adjust?: RevetmentAdjust | null,
|
||
): RevetmentLayout | null {
|
||
const spec = section.revetment;
|
||
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;
|
||
|
||
// 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽
|
||
// (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설
|
||
// 자리가 없으므로 그리지 않는다(면적도 그대로).
|
||
const fillSide = (): "left" | "right" | null => {
|
||
if (design.section_mode === "left_cut") return "right";
|
||
if (design.section_mode === "right_cut") return "left";
|
||
if (design.section_mode === "both_fill") return "left";
|
||
return null;
|
||
};
|
||
const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide();
|
||
if (!side) return null;
|
||
const outward = side === "left" ? 1 : -1;
|
||
const edge = design.road_edges?.[side];
|
||
const groundAt = groundInterpolator(section.samples);
|
||
const limit = sampleLimit(section, outward);
|
||
if (!edge || !groundAt || limit === null) return null;
|
||
|
||
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준,
|
||
// 2026-08-29 사용자). 재질(형태) 한계도 이 기준으로 자른다.
|
||
const pureHeight = Math.min(
|
||
Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1),
|
||
revetHeightLimit(spec.form),
|
||
);
|
||
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
|
||
const height = pureHeight - REVET_EMBED_DEPTH_M;
|
||
|
||
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
|
||
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
|
||
const lean = leanOf({
|
||
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
|
||
height_m: requestedHeight,
|
||
section_mode: design.section_mode,
|
||
side: spec.side,
|
||
face_role: spec.face_role,
|
||
face_slope_ratio: spec.face_slope_ratio,
|
||
});
|
||
const roadSlope = roadSlopePerOutward(design, side, outward);
|
||
const autoOffset =
|
||
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
|
||
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
|
||
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
|
||
/**
|
||
* 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직
|
||
* 하강(m)**. 성토선 각도는 그대로 두고 **노견 확장 구간**이 길이를 흡수한다
|
||
* (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다).
|
||
*/
|
||
const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN;
|
||
/** 그 자리에 벽을 세울 수 있나 — 매몰(원지반 아래 daylight 없음) 금지. */
|
||
const placeable = (xShift: number, drop: number): boolean => {
|
||
const anchor = autoOffset + outward * (autoRun + xShift);
|
||
const invert = edge.elevation_m - drop - height;
|
||
return (
|
||
invert >= groundAt(anchor) - 0.01 ||
|
||
slopedCrossing(
|
||
{ offset: anchor, elevation: invert },
|
||
outward,
|
||
-PIPE_CONNECT_GRADE,
|
||
groundAt,
|
||
limit,
|
||
) !== null
|
||
);
|
||
};
|
||
const requestedX = Math.max(adjust?.x ?? 0, 0);
|
||
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */
|
||
const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun);
|
||
let appliedDrop = Math.max(adjust?.d ?? defaultDrop, 0);
|
||
let appliedX = Math.max(requestedX, xFloor(appliedDrop));
|
||
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedDrop); pass += 1) {
|
||
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||
if (appliedDrop > 1e-9) {
|
||
appliedDrop = Math.max(0, appliedDrop - 0.05);
|
||
appliedX = Math.max(Math.min(appliedX, requestedX), xFloor(appliedDrop));
|
||
} else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
|
||
else break;
|
||
}
|
||
appliedX = Math.round(appliedX * 10) / 10;
|
||
appliedDrop = Math.round(appliedDrop * 10) / 10;
|
||
|
||
const anchorOffset = autoOffset + outward * (autoRun + appliedX);
|
||
const baseElevation = edge.elevation_m - appliedDrop - height;
|
||
const floatGapM = Math.max(0, baseElevation - groundAt(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: anchorOffset, elevation: baseElevation },
|
||
outward,
|
||
height,
|
||
material,
|
||
role: "outlet",
|
||
form: spec.form,
|
||
lengthM,
|
||
floatGapM,
|
||
lean,
|
||
});
|
||
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
|
||
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
|
||
// 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다.
|
||
const floorNow = xFloor(appliedDrop);
|
||
if (requestedX > 0.005 && floorNow > requestedX + 0.005) {
|
||
tier1.shiftFloorM = Math.round(floorNow * 100) / 100;
|
||
}
|
||
|
||
// 다단 — 배관과 같은 함수. 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 })),
|
||
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
|
||
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
|
||
ownerForm: spec.form,
|
||
equalize: false,
|
||
leanFor: (form, pureHeightM) =>
|
||
leanOf({
|
||
form,
|
||
height_m: pureHeightM,
|
||
section_mode: design.section_mode,
|
||
side: spec.side,
|
||
}),
|
||
})
|
||
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
|
||
|
||
const walls: WallLayout[] = [tier1, ...extras.walls];
|
||
const jt = tier1.topJoint;
|
||
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
|
||
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
|
||
// D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차
|
||
// (D − t) = 1.2 × (R0 + roadSlope × t) → t = (D − 1.2·R0) / (1 + 1.2·roadSlope)
|
||
const edgePoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||
const D = (jt.offset - edge.offset_m) * outward;
|
||
const R0 = edge.elevation_m - jt.elevation;
|
||
const denominator = 1 + FILL_SLOPE_RATIO_MIN * roadSlope;
|
||
const breakRun =
|
||
R0 > 1e-6 && D > 1e-6 && Math.abs(denominator) > 1e-9
|
||
? Math.min(D, Math.max(0, (D - FILL_SLOPE_RATIO_MIN * R0) / denominator))
|
||
: 0;
|
||
const slope = {
|
||
points:
|
||
breakRun > 1e-6
|
||
? [
|
||
edgePoint,
|
||
{
|
||
offset: edge.offset_m + outward * breakRun,
|
||
elevation: edge.elevation_m + roadSlope * breakRun,
|
||
},
|
||
jt,
|
||
]
|
||
: [edgePoint, 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: pureHeight,
|
||
chainageM: Number(section.chainage_m) || 0,
|
||
requestedTiers,
|
||
top: jt,
|
||
walls,
|
||
foundation: typeof spec.foundation === "string" ? spec.foundation.trim() || null : null,
|
||
fillSegments: extras.segments,
|
||
designTrim,
|
||
appliedAdjust: { x: appliedX, d: appliedDrop, 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),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */
|
||
const shortfallStations = new Set<number>();
|
||
let shortfallTimer = 0;
|
||
let shortfallLast = "";
|
||
|
||
function noticeShortfall(layout: RevetmentLayout): void {
|
||
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.walls.length}단만 세울 수 있습니다` +
|
||
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
|
||
"기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요.";
|
||
shortfallStations.clear();
|
||
if (message === shortfallLast) return;
|
||
shortfallLast = message;
|
||
showToast(message, "warning");
|
||
}, 400);
|
||
}
|
||
|
||
/** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */
|
||
export function appendRevetmentOverlay(
|
||
svg: SVGElement,
|
||
layout: RevetmentLayout | null,
|
||
x: (offset: number) => number,
|
||
y: (elevation: number) => number,
|
||
onSelect?: () => void,
|
||
): ((active: boolean) => void) | null {
|
||
if (!layout || !layout.walls.length) return null;
|
||
noticeShortfall(layout);
|
||
// 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다.
|
||
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)}`,
|
||
);
|
||
}
|
||
// 터파기는 벽보다 **먼저** — 파선이 벽 밑에 깔린다. 기초 축이 비어 있으면 안 그린다.
|
||
const trenchFoundation = foundationChoice(layout.foundation);
|
||
for (const wall of layout.walls) {
|
||
appendWallTrench(svg, wall, trenchFoundation, x, y);
|
||
}
|
||
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:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${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` : "") +
|
||
// 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다
|
||
// (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다.
|
||
(wall.shiftBlockedM
|
||
? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)`
|
||
: "") +
|
||
(wall.shiftFloorM
|
||
? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` +
|
||
` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임`
|
||
: "");
|
||
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);
|
||
}
|
||
}
|
||
return (active: boolean): void => {
|
||
for (const polygon of drawn) polygon.classList.toggle("is-active", active);
|
||
};
|
||
}
|