From 101a6a8f795ec51fbf7e409d0b93ad021f950666 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 28 Aug 2026 14:54:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EB=8F=85=EB=A6=BD=20=EA=B8=B0?= =?UTF-8?q?=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EB=8B=A4=EB=8B=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=EC=84=A0=20=ED=95=98=EB=8B=A8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9C=84=EB=A1=9C=20=EC=8C=93=EA=B3=A0=203D?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A8=EB=A7=88=EB=8B=A4=20=EC=84=B8=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- B05_Profile/B05_Profile_Structure_Types.json | 9 ++ B05_Profile/B05_Profile_UI_Corridor.ts | 4 +- .../B05_Profile_UI_Corridor_Structures.ts | 6 +- B06_Section/B06_Section_Engine_Revetment.py | 2 + B06_Section/B06_Section_UI_Cross_Revetment.ts | 105 +++++++++++++----- 5 files changed, 99 insertions(+), 27 deletions(-) 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; }