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:
@@ -992,6 +992,15 @@
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "tiers",
|
||||
"label": "단 수(다단)",
|
||||
"input": "number",
|
||||
"unit": "단",
|
||||
"default": 1,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "form",
|
||||
"label": "형태",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user