feat(B05,B06): 독립 기슭막이 다단 — 성토선 하단에서 위로 쌓고 3D도 단마다 세운다

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

- 레지스트리에 tiers(단 수, 기본 1 = 단일 벽) 추가. 서버가 스펙에 실어 보낸다.
- 자리 규칙: **성토선 하단(성토면 끝)이 1단**, 단을 늘리면 설계 성토면을 따라
  위로 올라가며 그 아래에 단이 들어온다(배관 세트와 다른 점은 관이 없고 시작
  자리가 성토선 하단이라는 것). 자리는 성토끝 → 노견 끝을 단 수로 등분해 잡는다.
- 3D는 단마다 솔리드를 스윕한다. BUILD_VERSION 87, 구조물 해시에 단 수 포함.

폐기한 시도: 배관 세트처럼 아래로 쌓기. 벽 상단이 이미 지반선이라 다음 단
시작점이 곧바로 원지반 아래가 되어 "묻힘" 가드에 걸렸다 — 3단 요청에 1단만
생성되는 것을 실측으로 확인하고 방향을 위로 바꿨다.

검증: pytest 241 passed / 7 skipped. 공용 브라우저 실측(285~315m·우측·높이 2.0·
3단) — 카드 3장 × 벽 3개 = 9개, 자리가 성토끝에서 노견 쪽으로 등간격
(315m: -5.42/538.10 → -4.28/538.94 → -3.14/539.77), 벽 하단은 상단 -2.5m
(높이 2.0 + 근입 0.5). 3D revet 솔리드 9개. 임시 데이터는 원래 정본으로 복구
(구조물 0 · 측점 23 · 관 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:54:57 +09:00
co-authored by Claude Opus 5
parent 05c4b30cab
commit 101a6a8f79
5 changed files with 99 additions and 27 deletions
@@ -992,6 +992,15 @@
"required": false,
"phase": "b05"
},
{
"key": "tiers",
"label": "단 수(다단)",
"input": "number",
"unit": "단",
"default": 1,
"required": false,
"phase": "b05"
},
{
"key": "form",
"label": "형태",
+3 -1
View File
@@ -199,7 +199,9 @@ function fnv1a(text: string): string {
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
const BUILD_VERSION = 86;
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
const BUILD_VERSION = 87;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -330,7 +330,10 @@ export function buildCorridorStructures(
};
if (revetLayout) {
pushSwept("revet", revetLayout.polygon, revetLayout.span.beforeM, revetLayout.span.afterM);
// 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다.
for (const tier of revetLayout.tiers) {
pushSwept("revet", tier.polygon, revetLayout.span.beforeM, revetLayout.span.afterM);
}
}
/** 링 누가거리 목록으로 프레임을 만든다(보어처럼 촘촘한 간격이 필요할 때). */
@@ -595,6 +598,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
ownRevet.height_m ?? "",
ownRevet.side ?? "",
ownRevet.form ?? "",
ownRevet.tiers ?? "",
);
}
const box = section.box;
@@ -45,6 +45,8 @@ def load_revetments(project_root: Path) -> list[dict[str, Any]]:
"form": options.get("form"),
"height_m": options.get("height_m"),
"side": options.get("side"),
# 다단 — 1이면 단일 벽. 전개 규칙은 화면·3D가 같은 산식으로 푼다.
"tiers": options.get("tiers"),
}
)
return found
+80 -25
View File
@@ -31,6 +31,8 @@ export interface RevetmentSpec {
height_m?: number | null;
/** 사용자가 고른 설치 측 — "좌" | "우". */
side?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
}
export interface RevetPoint {
@@ -38,12 +40,22 @@ export interface RevetPoint {
elevation: number;
}
export interface RevetmentLayout {
side: "left" | "right";
/** 벽 상단 자리 = 성토면 끝(설계선-지반 교차점). */
/** 한 단(段) — 벽 하나와, 그 아래 다음 단으로 내려가는 성토선. */
export interface RevetmentTier {
/** 벽 상단 자리(배면 = 도로측). 1단은 성토면 끝. */
top: RevetPoint;
/** 벽 하단 표고(근입 포함). */
bottomElevation: number;
/** 단면 폴리곤(도로측 수직 배면 → 상단 → 기운 전면 → 바닥). */
polygon: RevetPoint[];
}
export interface RevetmentLayout {
side: "left" | "right";
/** 1단 벽 상단 = 성토면 끝(설계선-지반 교차점). */
top: RevetPoint;
/** 단 목록 — 사용자가 지정한 단 수만큼, 벽이 지반에 묻히면 거기서 끝난다. */
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
}
@@ -57,7 +69,10 @@ function groundElevationAt(section: CrossSection, offsetM: number): number | nul
typeof sample.offset_m === "number" &&
typeof sample.elevation_m === "number",
)
.map((sample) => ({ offset: sample.offset_m as number, elevation: sample.elevation_m as 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];
@@ -107,28 +122,66 @@ function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint |
return { offset: tail.offset_m, elevation: tail.elevation_m };
}
/**
* 다단 자리 — **성토선 하단(성토면 끝)이 1단**이고, 단을 늘리면 성토 사면을 따라 위로
* 올라가며 그 아래에 단이 들어온다(2026-08-28 사용자 확정: 배관 세트와 다른 점은
* ① 관이 없고 ② 시작 자리가 성토선 하단이라는 것).
*
* 자리는 설계 성토면(설계선) 위를 성토끝 → 노견 끝 방향으로 n등분해 잡는다 — 사면을
* 같은 길이로 나누는 것이 다단의 목적이고, 새 수치를 만들지 않는다.
*/
function tierAnchors(
section: CrossSection,
side: "left" | "right",
toe: RevetPoint,
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에 가까울수록 노견 쪽
anchors.push({
offset: toe.offset + (edge.offset_m - toe.offset) * t,
elevation: toe.elevation + (edge.elevation_m - toe.elevation) * t,
});
}
return anchors;
}
/** 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. */
export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null {
const spec = section.revetment;
const height = Number(spec?.height_m);
if (!spec || !Number.isFinite(height) || height <= 0) return null;
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
const top = fillToeAt(section, side);
if (!top) return null;
const toe = fillToeAt(section, side);
if (!toe) return null;
const outward = side === "left" ? 1 : -1;
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);
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);
return {
top: anchor,
bottomElevation,
polygon: [
{ offset: anchor.offset, elevation: anchor.elevation },
{ offset: frontTop, elevation: anchor.elevation },
{ offset: frontBottom, elevation: bottomElevation },
{ offset: anchor.offset, elevation: bottomElevation },
],
};
});
return {
side,
top,
polygon: [
{ offset: top.offset, elevation: top.elevation },
{ offset: frontTop, elevation: top.elevation },
{ offset: frontBottom, elevation: bottomElevation },
{ offset: top.offset, elevation: bottomElevation },
],
top: toe,
tiers,
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
@@ -136,20 +189,22 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
};
}
/** 횡단 카드에 벽 단면을 그린다. 그렸으면 true. */
/** 횡단 카드에 벽 단면을 그린다(다단이면 단마다 하나). 그렸으면 true. */
export function appendRevetmentOverlay(
svg: SVGElement,
layout: RevetmentLayout | null,
x: (offset: number) => number,
y: (elevation: number) => number,
): boolean {
if (!layout) return false;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
layout.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "),
);
polygon.setAttribute("class", "b06-chart__revetment");
svg.append(polygon);
if (!layout || !layout.tiers.length) return false;
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(" "),
);
polygon.setAttribute("class", "b06-chart__revetment");
svg.append(polygon);
}
return true;
}