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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user