diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 5689b759..a90cc481 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -992,6 +992,15 @@ "required": false, "phase": "b05" }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05" + }, { "key": "form", "label": "형태", diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 8a2f9d5b..44b2280f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index a1f05911..9676bf6d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 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 diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index ab1a35f5..f48d5d39 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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; }