feat(B06): 독립 기슭막이 다단 — 기준이 맨 위, 추가 단은 아래로

2026-08-28 사용자 확정(자동 배치는 만들지 않는다).

- 자리: 기준(1단)이 맨 위이고 추가 단은 아래로 붙는다. 다음 단은 설계 성토면을
  따라 **벽 높이만큼 내려간 자리**에 서고, 성토면 끝을 지나면 자리가 없다.
- 자리가 없으면 배관 기슭막이처럼 토스트로 알린다("3단 중 1단만 세울 수
  있습니다 … 기준 올림으로 1단을 올린 뒤 단을 늘리세요"). 같은 측점을 다시
  그려도 한 번만 세도록 측점 단위로 집계한다.
- 이동 수단: 레지스트리에 lift_m(기준 올림·사면 위로) / shift_m(좌우) 추가.
  배관 세트의 조정창 4축 대신 구조물 옵션으로 받는다 — 조정창 연결은 별건.
- 3D는 단마다 솔리드를 스윕. BUILD_VERSION 88, 구조물 해시에 단 수·이동값 포함.

폐기한 두 시도: ① 배관 세트의 간격 규칙(하단 수평선 +근입 ↔ 전면 경사선) —
벽이 성토면 위에 서면 다음 단 시작점이 곧바로 지반 아래라 3단 요청에 1단만
섰다. ② 성토끝부터 위로 등분 — 기준이 아래가 되어 "기준=상단"과 어긋났다.

검증: pytest 241 passed / 7 skipped. 실측 — 높이 2.0·올림 0 → 1단 + 토스트,
높이 1.0·올림 5m → 315m에서 3단(540.6 → 539.6 → 538.6, 1.0m씩 하강),
3D revet 솔리드가 2D와 같은 수(285:2 · 300:2 · 315:3). 임시 구조물은 삭제하고
정본 복구(구조물 0 · 측점 23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 15:15:24 +09:00
co-authored by Claude Opus 5
parent 101a6a8f79
commit c6b7708984
5 changed files with 122 additions and 30 deletions
@@ -1001,6 +1001,24 @@
"required": false,
"phase": "b05"
},
{
"key": "lift_m",
"label": "기준 올림(사면 위로)",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
},
{
"key": "shift_m",
"label": "기준 좌우 이동",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
},
{
"key": "form",
"label": "형태",
+3 -1
View File
@@ -201,7 +201,9 @@ function fnv1a(text: string): string {
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
const BUILD_VERSION = 87;
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
const BUILD_VERSION = 88;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -599,6 +599,8 @@ export function structureHashParts(section: CrossSection): Array<string | number
ownRevet.side ?? "",
ownRevet.form ?? "",
ownRevet.tiers ?? "",
ownRevet.lift_m ?? "",
ownRevet.shift_m ?? "",
);
}
const box = section.box;
@@ -47,6 +47,9 @@ def load_revetments(project_root: Path) -> list[dict[str, Any]]:
"side": options.get("side"),
# 다단 — 1이면 단일 벽. 전개 규칙은 화면·3D가 같은 산식으로 푼다.
"tiers": options.get("tiers"),
# 자리 이동(사용자 조작) — 사면 위로 올림 / 좌우 이동.
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
}
)
return found
+96 -29
View File
@@ -10,6 +10,7 @@
* 단면 기하를 두 벌 만들지 않는다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
REVET_EMBED_DEPTH_M,
@@ -33,6 +34,10 @@ export interface RevetmentSpec {
side?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** 1단(기준·맨 위) 벽을 성토면 끝에서 도로 쪽으로 올린 사면 거리(m). 0이면 성토면 끝. */
lift_m?: number | null;
/** 기준 벽 좌우 이동(m) — + = 계류 쪽(바깥). 0이면 이동 없음. */
shift_m?: number | null;
}
export interface RevetPoint {
@@ -40,7 +45,7 @@ export interface RevetPoint {
elevation: number;
}
/** 한 단(段) — 벽 하나와, 그 아래 다음 단으로 내려가는 성토선. */
/** 한 단(段) — 벽 하나. 1단이 기준(맨 위)이고 추가 단은 아래로 붙는다. */
export interface RevetmentTier {
/** 벽 상단 자리(배면 = 도로측). 1단은 성토면 끝. */
top: RevetPoint;
@@ -52,6 +57,10 @@ export interface RevetmentTier {
export interface RevetmentLayout {
side: "left" | "right";
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 세는 데 쓴다(재렌더 중복 방지). */
chainageM: number;
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`tiers.length`)와 다르면 자리가 부족한 것이다. */
requestedTiers: number;
/** 1단 벽 상단 = 성토면 끝(설계선-지반 교차점). */
top: RevetPoint;
/** 단 목록 — 사용자가 지정한 단 수만큼, 벽이 지반에 묻히면 거기서 끝난다. */
@@ -123,34 +132,67 @@ function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint |
}
/**
* 단 자리 — **성토선 하단(성토면 끝)이 1단**이고, 단을 늘리면 성토 사면을 따라 위로
* 올라가며 그 아래에 단이 들어온다(2026-08-28 사용자 확정: 배관 세트와 다른 점은
* ① 관이 없고 ② 시작 자리가 성토선 하단이라는 것).
*
* 자리는 설계 성토면(설계선) 위를 성토끝 → 노견 끝 방향으로 n등분해 잡는다 — 사면을
* 같은 길이로 나누는 것이 다단의 목적이고, 새 수치를 만들지 않는다.
* 추가 단 자리 — 기준에서 **설계 성토면을 따라 아래로** 벽 높이만큼 내려간 지점.
* 계단처럼 한 단씩 내려가며, 성토면 끝(toe)을 지나면 더 놓을 자리가 없다
* (그 부족분은 화면이 토스트로 알린다 — 2026-08-28 사용자 확정).
*/
function tierAnchors(
section: CrossSection,
side: "left" | "right",
anchor: RevetPoint,
toe: RevetPoint,
height: number,
count: number,
): RevetPoint[] {
const design = section.design;
const edge = design?.road_edges?.[side];
if (!edge || count <= 1) return [toe];
const anchors: RevetPoint[] = [];
for (let index = 0; index < count; index += 1) {
const t = index / count; // 0 = 성토끝(맨 아래 단), 1에 가까울수록 노견 쪽
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: toe.offset + (edge.offset_m - toe.offset) * t,
elevation: toe.elevation + (edge.elevation_m - toe.elevation) * t,
offset: anchor.offset + (run * fall) / drop,
elevation: anchor.elevation - fall,
});
}
return anchors;
}
/** 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. */
function anchorAt(
section: CrossSection,
side: "left" | "right",
toe: RevetPoint,
liftM: number,
shiftM: 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 };
}
}
if (!shiftM) return anchor;
// 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게).
const outward = side === "left" ? 1 : -1;
const offset = anchor.offset + outward * shiftM;
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.
*/
export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null {
const spec = section.revetment;
const height = Number(spec?.height_m);
@@ -160,27 +202,29 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
if (!toe) return null;
const outward = side === "left" ? 1 : -1;
const count = Math.max(1, Math.round(Number(spec.tiers) || 1));
const tiers: RevetmentTier[] = tierAnchors(section, side, toe, count).map((anchor) => {
const bottomElevation = anchor.elevation - height - REVET_EMBED_DEPTH_M;
const frontTop = anchor.offset + outward * REVET_THICKNESS_M;
const frontBottom =
frontTop + outward * REVET_LEAN_RATIO * (anchor.elevation - bottomElevation);
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
const anchor = anchorAt(section, side, toe, Number(spec.lift_m) || 0, Number(spec.shift_m) || 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: anchor,
top,
bottomElevation,
polygon: [
{ offset: anchor.offset, elevation: anchor.elevation },
{ offset: frontTop, elevation: anchor.elevation },
{ offset: top.offset, elevation: top.elevation },
{ offset: frontTop, elevation: top.elevation },
{ offset: frontBottom, elevation: bottomElevation },
{ offset: anchor.offset, elevation: bottomElevation },
{ offset: top.offset, elevation: bottomElevation },
],
};
});
return {
side,
top: toe,
chainageM: Number(section.chainage_m) || 0,
requestedTiers,
top: tiers[0]?.top ?? anchor,
tiers,
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
@@ -189,6 +233,28 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
};
}
/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */
const shortfallStations = new Set<number>();
let shortfallTimer = 0;
let shortfallLast = "";
function noticeShortfall(layout: RevetmentLayout): void {
if (layout.tiers.length >= layout.requestedTiers) return;
// 같은 측점이 다시 그려져도 한 번만 센다 — 카드는 조작할 때마다 다시 그린다.
shortfallStations.add(Math.round(layout.chainageM * 100));
window.clearTimeout(shortfallTimer);
shortfallTimer = window.setTimeout(() => {
const message =
`기슭막이 ${layout.requestedTiers}단 중 ${layout.tiers.length}단만 세울 수 있습니다` +
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
"기준 올림(사면 위로)으로 1단을 올린 뒤 단을 늘리세요.";
shortfallStations.clear();
if (message === shortfallLast) return;
shortfallLast = message;
showToast(message, "warning");
}, 400);
}
/** 횡단 카드에 벽 단면을 그린다(다단이면 단마다 하나). 그렸으면 true. */
export function appendRevetmentOverlay(
svg: SVGElement,
@@ -197,6 +263,7 @@ export function appendRevetmentOverlay(
y: (elevation: number) => number,
): boolean {
if (!layout || !layout.tiers.length) return false;
noticeShortfall(layout);
for (const tier of layout.tiers) {
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(