diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a90cc481..478b3871 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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": "형태", diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 44b2280f..a1db8ad1 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 9676bf6d..405c1219 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -599,6 +599,8 @@ export function structureHashParts(section: CrossSection): Array 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 diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index f48d5d39..53c0facb 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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(); +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(