feat(B05): 측구 끊김 마감·0.1m 종단 세분·지형 near/far 분할·횡단배수 최소고 경고

측구 표현
- 한쪽 측점에만 있으면 측점 사이 중간에서 끊고, 끊긴 자리를 도로 가장자리
  높이까지 벽으로 막는다(공중에서 끊겨 보이던 문제)

품질·성능
- 종단 세분 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:58:00 +09:00
co-authored by Claude Opus 5
parent fdfc0fb893
commit af9d2b5530
7 changed files with 431 additions and 59 deletions
+34 -13
View File
@@ -52,8 +52,9 @@ const PIECE_COLS: Record<CorridorKind, number> = {
};
/** 종방향 세분 간격(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 };
}
+57 -42
View File
@@ -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<number>)[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;
}
@@ -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<number>;
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) };
}
@@ -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(
@@ -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<string, string | number>;
}
/** 최소 토피(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}`;
}
+34 -1
View File
@@ -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),
+53 -3
View File
@@ -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();
},