From af9d2b5530346b49c2e098a05d8c53edf8c4c873 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 13:58:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=B8=A1=EA=B5=AC=20=EB=81=8A?= =?UTF-8?q?=EA=B9=80=20=EB=A7=88=EA=B0=90=C2=B70.1m=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=EC=84=B8=EB=B6=84=C2=B7=EC=A7=80=ED=98=95=20near/far=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=C2=B7=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=EA=B3=A0=20=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 측구 표현 - 한쪽 측점에만 있으면 측점 사이 중간에서 끊고, 끊긴 자리를 도로 가장자리 높이까지 벽으로 막는다(공중에서 끊겨 보이던 문제) 품질·성능 - 종단 세분 2m → 0.1m (사용자 지정). 프레임 갭 실측 145ms로 사용 가능 - Corridor_Split(신규): 노선 밴드(코리도 AABB + 80m)로 지형을 1회만 갈라 편집마다 near만 재트림, far는 재사용. 정점 버퍼 공유라 분할 비용은 인덱스 복사뿐이며, 코리도가 밴드를 벗어날 때만 다시 가른다 횡단배수 최소 계획고 - Profile_MinCover(신규): 배수관 Ø1000 → 지반고 +1.5m, BOX 2.0×2.0 → +2.5m (관경·구체높이 + 토피 0.5m, B06 MIN_PIPE_COVER_M과 동일 값) - 계획선이 밑돌면 종단 표시줄 경고 + 측점별 부족량 툴팁. 세월교·물넘이는 제외 - 자동 계획선 생성 시 제약 반영은 후속(선형 재구성 규칙 협의 필요) 검증: tsc·pytest 9/9, 화면 실측(경고 문구·측구 마감·클리핑 삼각형 수) Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 47 +++++-- B05_Profile/B05_Profile_UI_Corridor_Clip.ts | 99 ++++++++------ B05_Profile/B05_Profile_UI_Corridor_Split.ts | 121 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Balance.ts | 15 +++ .../B05_Profile_UI_Profile_MinCover.ts | 117 +++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 35 ++++- B05_Profile/B05_Profile_UI_Viewer.ts | 56 +++++++- 7 files changed, 431 insertions(+), 59 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Split.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_MinCover.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index c2ab7511..9dd49c43 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -52,8 +52,9 @@ const PIECE_COLS: Record = { }; /** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). - * 1m로 좁혀 종단 방향 서피스를 더 부드럽게 한다(2026-08-23 사용자 요청). */ -const SUBDIVIDE_STEP_M = 1; + * 0.1m로 좁혀 종단 방향 서피스를 최대한 부드럽게 한다(2026-08-23 사용자 지정). + * 느려지면 재조정하기로 한 값이다. */ +const SUBDIVIDE_STEP_M = 0.1; interface XY { x: number; @@ -477,6 +478,24 @@ export function buildCorridor( point.elevation_m, ]; + const caps: CorridorCap[] = []; + /** + * 측구가 끊기는 자리의 마감벽 — 측구 단면을 **도로 가장자리 높이까지** 세로로 + * 막는다(2026-08-23 사용자 지적: 지금은 공중에서 끊겨 보인다). 위쪽 모서리를 + * 도로 쪽 끝점 높이로 두면 도로 옆면에 붙은 벽으로 마감된다. + */ + const pushDitchCap = (row: (typeof rows)[number], key: string, side: CorridorSide): void => { + const points = row.sections.get(key); + if (!points || points.length < 2) return; + const roadSide = side === "right" ? points[points.length - 1] : points[0]; + caps.push({ + points: points.map((point) => { + const [x, y] = toModel(row, point); + return [x, y, roadSide.elevation_m, point.elevation_m] as [number, number, number, number]; + }), + }); + }; + const ribbons: CorridorRibbon[] = []; keys.forEach((key) => { const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; @@ -503,6 +522,11 @@ export function buildCorridor( }); } ribbons.push({ kind, side, colCount, chainages, positions }); + // 측구 리본이 노선 중간에서 시작·종료하면 그 자리를 벽으로 막는다. + if (kind === "ditch") { + if (start > 0) pushDitchCap(rows[start], key, side); + if (endExclusive < rows.length) pushDitchCap(rows[endExclusive - 1], key, side); + } start = -1; }; for (let row = 0; row < rows.length; row += 1) { @@ -525,16 +549,13 @@ export function buildCorridor( }); // 시·종점 마구리 — 코리도가 끊기는 자리를 설계선↔지반선으로 봉인해 속이 안 보이게 한다. - const caps: CorridorCap[] = [rows[0], rows[rows.length - 1]] - .map((row, index) => { - const station = index === 0 ? stations[0] : stations[stations.length - 1]; - return { - points: station.capSection.map(([offset, zDesign, zGround]) => { - const [x, y] = toModel(row, { offset_m: offset, elevation_m: zDesign }); - return [x, y, zDesign, zGround] as [number, number, number, number]; - }), - }; - }) - .filter((cap) => cap.points.length >= 2); + [rows[0], rows[rows.length - 1]].forEach((row, index) => { + const station = index === 0 ? stations[0] : stations[stations.length - 1]; + const points = station.capSection.map(([offset, zDesign, zGround]) => { + const [x, y] = toModel(row, { offset_m: offset, elevation_m: zDesign }); + return [x, y, zDesign, zGround] as [number, number, number, number]; + }); + if (points.length >= 2) caps.push({ points }); + }); return { ribbons, outline, caps }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts index 9d990d69..0eb6fcd6 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -17,6 +17,7 @@ import * as THREE from "three"; import type { ModelBounds } from "./B05_Profile_UI_Markers"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; interface P2 { x: number; @@ -448,58 +449,72 @@ export function clipGeometry( return clipped; } +/** 포인트클라우드(meshfree) — 스트립 안 점만 걷어낸다. */ +function clipPoints(geometry: THREE.BufferGeometry, strip: CorridorStrip): THREE.BufferGeometry { + const position = geometry.getAttribute("position") as THREE.BufferAttribute; + const keep: number[] = []; + for (let i = 0; i < position.count; i += 1) { + if (!strip.contains({ x: position.getX(i), z: position.getZ(i) })) keep.push(i); + } + const positions = new Float32Array(keep.length * 3); + keep.forEach((src, dst) => { + positions[dst * 3] = position.getX(src); + positions[dst * 3 + 1] = position.getY(src); + positions[dst * 3 + 2] = position.getZ(src); + }); + const clipped = new THREE.BufferGeometry(); + clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + if (color) { + const size = color.itemSize; + const colors = new Float32Array(keep.length * size); + keep.forEach((src, dst) => { + for (let c = 0; c < size; c += 1) { + colors[dst * size + c] = (color.array as ArrayLike)[src * size + c]; + } + }); + clipped.setAttribute("color", new THREE.BufferAttribute(colors, size, color.normalized)); + } + return clipped; +} + /** - * 지형 전체(Object3D 트리)의 클리핑본을 만든다 — 원본은 불변. - * Mesh는 재절단, Points(meshfree)는 스트립 내부 점 제거. 재질은 clone해 - * 원본과 dispose 수명을 분리한다. + * 코리도 스트립으로 도려낸 지형을 만든다 — 원본은 불변. + * + * 분할본(TerrainBandSplit)을 받으면 **노선 주변(near)만** 재트림하고 원거리(far)는 + * 처음 만든 그대로 다시 쓴다(2026-08-23 사용자 제안). 편집마다 지형 전체를 + * 훑던 비용이 밴드 안쪽으로 줄어든다. */ export function clipTerrain( - terrain: THREE.Object3D, + split: TerrainBandSplit, build: CorridorBuildResult, bounds: ModelBounds, ): THREE.Object3D { const strip = new CorridorStrip(build, bounds); const root = new THREE.Group(); root.name = "terrain-clipped"; - terrain.updateMatrixWorld(true); - terrain.traverse((child) => { - if (child instanceof THREE.Mesh) { - const source = child.geometry.clone().applyMatrix4(child.matrixWorld); - const clipped = clipGeometry(source, strip) ?? source; - if (clipped !== source) source.dispose(); - const material = Array.isArray(child.material) - ? child.material.map((m) => m.clone()) - : child.material.clone(); - root.add(new THREE.Mesh(clipped, material)); - } else if (child instanceof THREE.Points) { - const source = child.geometry.clone().applyMatrix4(child.matrixWorld); - const position = source.getAttribute("position") as THREE.BufferAttribute | undefined; - if (!position) return; - const keep: number[] = []; - for (let i = 0; i < position.count; i += 1) { - if (!strip.contains({ x: position.getX(i), z: position.getZ(i) })) keep.push(i); - } - const positions = new Float32Array(keep.length * 3); - keep.forEach((src, dst) => { - positions[dst * 3] = position.getX(src); - positions[dst * 3 + 1] = position.getY(src); - positions[dst * 3 + 2] = position.getZ(src); - }); - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const color = source.getAttribute("color") as THREE.BufferAttribute | undefined; - if (color) { - const colors = new Float32Array(keep.length * 3); - keep.forEach((src, dst) => { - colors[dst * 3] = color.getX(src); - colors[dst * 3 + 1] = color.getY(src); - colors[dst * 3 + 2] = color.getZ(src); - }); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - } - source.dispose(); - root.add(new THREE.Points(geometry, (child.material as THREE.Material).clone())); + // geometry·material 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다. + // 이 그룹이 새로 만든 것(userData.owned)만 disposeClippedTerrain이 정리한다. + split.near.forEach((part) => { + if (part.points) { + const points = new THREE.Points( + clipPoints(part.geometry, strip), + part.material as THREE.Material, + ); + points.userData.owned = true; + root.add(points); + return; } + const clipped = clipGeometry(part.geometry, strip); + const mesh = new THREE.Mesh(clipped ?? part.geometry, part.material); + mesh.userData.owned = clipped !== null; + root.add(mesh); + }); + // 원거리 조각은 코리도와 무관하다 — 그대로 얹는다(재트림·재생성 없음). + split.far.forEach((part) => { + const mesh = new THREE.Mesh(part.geometry, part.material); + mesh.userData.owned = false; + root.add(mesh); }); return root; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Split.ts b/B05_Profile/B05_Profile_UI_Corridor_Split.ts new file mode 100644 index 00000000..a51503b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Split.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Split.ts + * 지형 서피스를 코리도 주변(near) / 원거리(far)로 **한 번만** 갈라 둔다. + * + * 왜(2026-08-23 사용자 제안): 예상형상은 지형 전체에서 가느다란 띠다. 계획선을 + * 편집할 때마다 지형 48만 삼각형을 통째로 다시 트림하면 낭비다 — 노선 주변 + * 밴드만 재트림하고, 그 밖은 처음 만든 그대로 다시 쓴다. 밴드는 편집으로 코리도 + * 폭이 늘어나도 견디도록 넉넉히 잡고, 벗어나면 그때만 다시 가른다. + * ========================================================================== */ + +import * as THREE from "three"; + +/** 밴드 여유(m) — 계획고를 크게 올려 성토가 넓어져도 밴드 안에 들어오게. */ +export const BAND_MARGIN_M = 80; + +export interface SceneBox { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +/** 지형 조각 하나 — 씬 좌표로 구운 geometry와 그릴 재질. */ +export interface TerrainPart { + geometry: THREE.BufferGeometry; + material: THREE.Material | THREE.Material[]; + points: boolean; +} + +export class TerrainBandSplit { + readonly band: SceneBox; + readonly near: TerrainPart[] = []; + readonly far: TerrainPart[] = []; + + constructor(terrain: THREE.Object3D, band: SceneBox) { + this.band = band; + terrain.updateMatrixWorld(true); + terrain.traverse((child) => { + if (child instanceof THREE.Points) { + // 포인트클라우드(meshfree)는 통째로 near에 둔다 — 클리핑이 점 단위라 가볍다. + this.near.push({ + geometry: child.geometry.clone().applyMatrix4(child.matrixWorld), + material: (child.material as THREE.Material).clone(), + points: true, + }); + return; + } + if (!(child instanceof THREE.Mesh)) return; + const source = child.geometry.clone().applyMatrix4(child.matrixWorld); + const split = splitGeometryByBand(source, band); + const material = Array.isArray(child.material) + ? child.material.map((m) => m.clone()) + : child.material.clone(); + if (split.near) this.near.push({ geometry: split.near, material, points: false }); + if (split.far) { + const farMaterial = Array.isArray(material) + ? material.map((m) => m.clone()) + : material.clone(); + this.far.push({ geometry: split.far, material: farMaterial, points: false }); + } + if (!split.near && !split.far) source.dispose(); + }); + } + + /** 지정 상자가 밴드 안에 완전히 들어오는가 — 아니면 다시 갈라야 한다. */ + covers(box: SceneBox): boolean { + return ( + box.minX >= this.band.minX && + box.maxX <= this.band.maxX && + box.minZ >= this.band.minZ && + box.maxZ <= this.band.maxZ + ); + } + + dispose(): void { + [...this.near, ...this.far].forEach((part) => { + part.geometry.dispose(); + (Array.isArray(part.material) ? part.material : [part.material]).forEach((m) => m.dispose()); + }); + } +} + +/** + * 삼각형 소속을 밴드로 갈라 인덱스만 나눈다 — 정점 버퍼는 두 조각이 **공유**하므로 + * 쪼개는 비용이 인덱스 복사뿐이다(정점 복사·재배치 없음). + */ +function splitGeometryByBand( + geometry: THREE.BufferGeometry, + band: SceneBox, +): { near: THREE.BufferGeometry | null; far: THREE.BufferGeometry | null } { + const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined; + if (!position) return { near: null, far: null }; + const px = position.array as ArrayLike; + const index = geometry.getIndex(); + const triCount = index ? index.count / 3 : position.count / 3; + const nearIndices: number[] = []; + const farIndices: number[] = []; + for (let t = 0; t < triCount; t += 1) { + const ia = index ? index.getX(t * 3) : t * 3; + const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1; + const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2; + const minX = Math.min(px[ia * 3], px[ib * 3], px[ic * 3]); + const maxX = Math.max(px[ia * 3], px[ib * 3], px[ic * 3]); + const minZ = Math.min(px[ia * 3 + 2], px[ib * 3 + 2], px[ic * 3 + 2]); + const maxZ = Math.max(px[ia * 3 + 2], px[ib * 3 + 2], px[ic * 3 + 2]); + const outside = maxX < band.minX || minX > band.maxX || maxZ < band.minZ || minZ > band.maxZ; + (outside ? farIndices : nearIndices).push(ia, ib, ic); + } + const make = (indices: number[]): THREE.BufferGeometry | null => { + if (!indices.length) return null; + const part = new THREE.BufferGeometry(); + part.setAttribute("position", position); + const color = geometry.getAttribute("color"); + if (color) part.setAttribute("color", color); + const normal = geometry.getAttribute("normal"); + if (normal) part.setAttribute("normal", normal); + part.setIndex(indices); + return part; + }; + return { near: make(nearIndices), far: make(farIndices) }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 46b589a6..df555b41 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -15,6 +15,7 @@ * ========================================================================== */ import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import { minCoverWarningText, type MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -29,6 +30,8 @@ export interface BalanceBarParams { hasIrregularStations: boolean; /** 저장되지 않은 편집이 있는지. */ dirty: boolean; + /** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */ + minCoverViolations?: MinCoverViolation[]; /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -71,6 +74,10 @@ export function renderBalanceBar(params: BalanceBarParams): void { ]; // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); + // 횡단배수 최소 계획고(2026-08-23 사용자 지시) — 관경·구체높이 + 토피 0.5m를 + // 밑도는 측점이 있으면 경고한다. 계획선을 대신 올려 주지는 않는다(사용자 판단). + const coverWarning = minCoverWarningText(params.minCoverViolations ?? []); + if (coverWarning) entries.push(["횡단배수 최소고", coverWarning, "over"]); const editedCount = Object.keys(alignment.edits.station_offsets).length; if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { @@ -81,6 +88,14 @@ export function renderBalanceBar(params: BalanceBarParams): void { item.append(caption, document.createTextNode(value)); // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). + if (label === "횡단배수 최소고" && params.minCoverViolations?.length) { + item.title = params.minCoverViolations + .map( + (entry) => + `${entry.chainage_m.toFixed(1)}m ${entry.label}: 계획고 ${entry.planned_m.toFixed(2)} < 최소 ${entry.required_m.toFixed(2)} (부족 ${entry.shortfall_m.toFixed(2)}m)`, + ) + .join("\n"); + } if (label === "최대 기울기" && violations.length) { item.title = violations .map( diff --git a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts new file mode 100644 index 00000000..7bad1c15 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_MinCover.ts + * 횡단배수 시설이 요구하는 **측점별 최소 계획고**를 산출한다. + * + * 왜(2026-08-23 사용자 지시): 배수관·BOX암거는 관(또는 구체) 위에 최소 토피가 + * 있어야 성립한다. 계획선이 그 아래로 내려오면 3D 형상도, 실제 시공도 성립하지 + * 않는다. 그래서 시설 제원으로 최소 계획고를 되짚어 종단에 표시하고, 계획선이 + * 그 아래로 내려오면 경고한다. + * + * 산식(사용자 확정 예시 그대로): + * 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5 + * BOX암거 2.0×2.0 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5 + * 토피 0.5m는 B06 배수관 엔진의 `MIN_PIPE_COVER_M`과 같은 값이다(교차 확인). + * 세월교·물넘이포장은 월류 구조라 최소 토피 개념이 다르다 — 이번 범위에서 뺀다. + * ========================================================================== */ + +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */ +export interface MinCoverPipe { + chainage_m: number; + facility?: PipeFacility; + options?: Record; +} + +/** 최소 토피(m) — B06 `MIN_PIPE_COVER_M`과 같은 값. */ +export const MIN_COVER_M = 0.5; + +/** 시설 제원이 비었을 때 쓰는 기본값 — 화면 기본 선택과 맞춘다. */ +const DEFAULT_PIPE_DIAMETER_MM = 1000; +const DEFAULT_BOX_HEIGHT_M = 2; + +export interface MinCoverPoint { + chainage_m: number; + /** 시설이 요구하는 지반고 대비 최소 여유(m) — 관경/구체높이 + 토피. */ + clearance_m: number; + /** 표시용 시설 이름(경고 문구에 쓴다). */ + label: string; +} + +function numberOf(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * 관 지점 목록에서 최소 여유가 필요한 측점을 뽑는다. + * 배수관·BOX암거만 대상(세월교·물넘이포장 제외). + */ +export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] { + const result: MinCoverPoint[] = []; + for (const pipe of pipes) { + const facility = pipe.facility ?? "pipe"; + const options = pipe.options ?? {}; + if (facility === "pipe") { + const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000; + result.push({ + chainage_m: pipe.chainage_m, + clearance_m: diameterM + MIN_COVER_M, + label: `배수관 Ø${Math.round(diameterM * 1000)}`, + }); + } else if (facility === "box_culvert") { + const heightM = numberOf(options.body_height_m, DEFAULT_BOX_HEIGHT_M); + result.push({ + chainage_m: pipe.chainage_m, + clearance_m: heightM + MIN_COVER_M, + label: `BOX암거 H${heightM.toFixed(1)}`, + }); + } + } + return result.sort((a, b) => a.chainage_m - b.chainage_m); +} + +export interface MinCoverViolation extends MinCoverPoint { + /** 요구 최소 계획고(m). */ + required_m: number; + /** 현재 계획고(m). */ + planned_m: number; + /** 모자란 높이(m, 양수). */ + shortfall_m: number; +} + +/** + * 계획선이 최소 계획고에 못 미치는 측점을 찾는다. + * groundAt/planAt은 chainage로 지반고·계획고를 돌려주는 보간기다. + */ +export function findMinCoverViolations( + points: MinCoverPoint[], + groundAt: (chainageM: number) => number | null, + planAt: (chainageM: number) => number | null, + toleranceM = 0.001, +): MinCoverViolation[] { + const violations: MinCoverViolation[] = []; + for (const point of points) { + const ground = groundAt(point.chainage_m); + const planned = planAt(point.chainage_m); + if (ground === null || planned === null) continue; + const required = ground + point.clearance_m; + if (planned < required - toleranceM) { + violations.push({ + ...point, + required_m: required, + planned_m: planned, + shortfall_m: required - planned, + }); + } + } + return violations; +} + +/** 경고 문구 — 가장 크게 모자란 곳부터 알려 준다. */ +export function minCoverWarningText(violations: MinCoverViolation[]): string | null { + if (!violations.length) return null; + const worst = [...violations].sort((a, b) => b.shortfall_m - a.shortfall_m)[0]; + const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : ""; + return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 27f933b0..d519b683 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -29,6 +29,11 @@ import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; +import { + findMinCoverViolations, + minCoverPoints, + type MinCoverPoint, +} from "./B05_Profile_UI_Profile_MinCover"; import type { AlignmentBase, AlignmentEdits, @@ -181,8 +186,15 @@ export function createRouteProfilePanel( const content = document.createElement("div"); content.className = "b05-route-profile__content"; // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). + /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ + let minCoverTargets: MinCoverPoint[] = []; const drainagePanel = createDrainagePanel({ - onPipesChanged: (pipes) => callbacks?.onPipesChanged?.(pipes), + onPipesChanged: (pipes) => { + // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. + minCoverTargets = minCoverPoints(pipes); + renderBalance(); + callbacks?.onPipesChanged?.(pipes); + }, onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM), // 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록 @@ -338,8 +350,29 @@ export function createRouteProfilePanel( onSelectStation(next); } + /** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */ + function sampleAt(chainageM: number, field: "elevation_m" | "ground_elevation_m"): number | null { + const samples = alignment?.samples; + if (!samples?.length) return null; + if (chainageM <= samples[0].chainage_m) return samples[0][field]; + for (let i = 1; i < samples.length; i += 1) { + if (chainageM <= samples[i].chainage_m) { + const span = samples[i].chainage_m - samples[i - 1].chainage_m; + const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span; + return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t; + } + } + return samples[samples.length - 1][field]; + } + function renderBalance(): void { + const minCoverViolations = findMinCoverViolations( + minCoverTargets, + (chainageM) => sampleAt(chainageM, "ground_elevation_m"), + (chainageM) => sampleAt(chainageM, "elevation_m"), + ); renderBalanceBar({ + minCoverViolations, balanceBar, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 7f1bfb4e..f7a63379 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -18,6 +18,7 @@ import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh"; import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; +import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; @@ -166,6 +167,8 @@ export function createRouteViewer(): RouteViewer { let clippedTerrain: THREE.Object3D | null = null; /** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */ let heightIndex: TerrainHeightIndex | null = null; + /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ + let bandSplit: TerrainBandSplit | null = null; let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). const contours = new THREE.Group(); @@ -455,10 +458,52 @@ export function createRouteViewer(): RouteViewer { function disposeClippedTerrain(): void { if (!clippedTerrain) return; scene.remove(clippedTerrain); - disposeObject(clippedTerrain); + // 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에 + // 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다. + clippedTerrain.traverse((child) => { + if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return; + if (child.userData.owned) child.geometry.dispose(); + }); clippedTerrain = null; } + /** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */ + function corridorBand(build: CorridorBuildResult): SceneBox | null { + if (!bounds) return null; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + [...build.outline.left, ...build.outline.right].forEach(([mx, my]) => { + const x = mx - ox; + const z = -(my - oy); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + }); + if (!Number.isFinite(minX)) return null; + return { minX, maxX, minZ, maxZ }; + } + + /** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */ + function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null { + if (!terrain) return null; + const box = corridorBand(build); + if (!box) return null; + if (bandSplit?.covers(box)) return bandSplit; + bandSplit?.dispose(); + bandSplit = new TerrainBandSplit(terrain, { + minX: box.minX - BAND_MARGIN_M, + maxX: box.maxX + BAND_MARGIN_M, + minZ: box.minZ - BAND_MARGIN_M, + maxZ: box.maxZ + BAND_MARGIN_M, + }); + return bandSplit; + } + function disposeCorridorGroup(): void { if (!corridorGroup) return; scene.remove(corridorGroup); @@ -520,7 +565,9 @@ export function createRouteViewer(): RouteViewer { requestAnimationFrame(() => { // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const clipped = clipTerrain(terrain, buildAtSchedule, bounds); + const split = ensureBandSplit(buildAtSchedule); + if (!split) return; + const clipped = clipTerrain(split, buildAtSchedule, bounds); disposeClippedTerrain(); clippedTerrain = clipped; scene.add(clipped); @@ -558,7 +605,9 @@ export function createRouteViewer(): RouteViewer { scene.remove(terrain); disposeObject(terrain); } - heightIndex = null; // 지형이 바뀌면 높이 색인도 새로 만든다. + heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. + bandSplit?.dispose(); + bandSplit = null; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). const buffer = await fetchCachedBytes(projectId, url); @@ -633,6 +682,7 @@ export function createRouteViewer(): RouteViewer { corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); + bandSplit?.dispose(); controls.dispose(); renderer.dispose(); },