fix(B05): 코리도 클리핑 크래시·지형 렌더 복구 + 측구 구간 분할 + 종단 스무딩

클리핑 정상화 (절토가 원지반에 묻혀 안 보이던 원인)
- 정점 버퍼를 count 길이로 잘라 복사 — 'offset is out of bounds'로 클리핑
  전체가 죽어 원지반이 하나도 안 잘리고 있었다(삼각형 485,358 → 477,239 확인)
- 정점색을 원본 배열 타입·normalized 그대로 복사(GLB는 정규화 Uint8 RGBA)
- 원본에 법선이 없으면 클리핑본도 만들지 않는다 — 직접 계산하면 감기 방향이
  원본과 달라 지형이 새까맣게 렌더됐다

표현 규칙
- 측구가 한쪽 측점에만 있으면 측점 사이 중간에서 끊는다(사용자 확정).
  폭 0 축퇴로 잇던 방식을 걷어내 없는 자리의 서피스·윤곽선 제거
- 리본을 조각이 존재하는 연속 구간별로 분리 생성
- 종단 세분 2m → 1m, 측점 사이 표고를 종단 계획선(종단곡선 포함) 기준으로
  보정 — 도로·측구는 전량, 비탈은 지반 접점까지 감쇠
- 측점 가로선을 예상형상 표시 중에는 계획고 위로 올림
- 지형 기본 표시를 흑백으로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:42:16 +09:00
co-authored by Claude Opus 5
parent 7fdbb343ab
commit fdfc0fb893
6 changed files with 249 additions and 50 deletions
+6 -1
View File
@@ -213,7 +213,12 @@ export async function ensureCorridor(
}
}
const build = buildCorridor(detail.cross_sections, routePoints);
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
const build = buildCorridor(
detail.cross_sections,
routePoints,
detail.longitudinal.design_profiles?.[0]?.samples,
);
if (!build) {
cache.delete(key);
return null;
+109 -22
View File
@@ -51,8 +51,9 @@ const PIECE_COLS: Record<CorridorKind, number> = {
fill: 9,
};
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). */
const SUBDIVIDE_STEP_M = 2;
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분).
* 1m로 좁혀 종단 방향 서피스를 더 부드럽게 한다(2026-08-23 사용자 요청). */
const SUBDIVIDE_STEP_M = 1;
interface XY {
x: number;
@@ -317,6 +318,48 @@ function lerpPoints(a: OffsetPoint[], b: OffsetPoint[], t: number): OffsetPoint[
}));
}
/** 종단 계획선 표고 보간기 — chainage로 계획고를 되짚는다(종단곡선 포함). */
function buildProfileSampler(
samples?: Array<{ chainage_m: number; elevation_m: number }>,
): ((chainage: number) => number | null) | null {
if (!samples || samples.length < 2) return null;
const sorted = [...samples].sort((a, b) => a.chainage_m - b.chainage_m);
return (chainage: number): number | null => {
if (chainage <= sorted[0].chainage_m) return sorted[0].elevation_m;
for (let i = 1; i < sorted.length; i += 1) {
if (chainage <= sorted[i].chainage_m) {
const span = sorted[i].chainage_m - sorted[i - 1].chainage_m;
const t = span <= 1e-12 ? 0 : (chainage - sorted[i - 1].chainage_m) / span;
return sorted[i - 1].elevation_m + (sorted[i].elevation_m - sorted[i - 1].elevation_m) * t;
}
}
return sorted[sorted.length - 1].elevation_m;
};
}
/**
* 종단 보정량을 단면에 싣는다. 도로·노견·측구는 계획고에 붙어 있으므로 통째로,
* 절·성토 비탈은 안쪽(도로측) 전량 → 바깥쪽(지반 접점) 0으로 감쇠시킨다 —
* 그래야 비탈 끝이 지반에서 떨어지지 않는다.
*/
function applyProfileShift(
points: OffsetPoint[],
kind: CorridorKind,
side: CorridorSide,
shift: number,
): void {
if (kind !== "cut" && kind !== "fill") {
points.forEach((point) => (point.elevation_m += shift));
return;
}
const last = points.length - 1;
points.forEach((point, index) => {
// 좌측 비탈은 index 0이 도로측, 우측 비탈은 index last가 도로측(offset 부호 반대).
const innerRatio = side === "right" ? index / last : 1 - index / last;
point.elevation_m += shift * innerRatio;
});
}
/** 조각이 없는 측점의 대응 폴리라인 — road edge 한 점으로 축퇴(리본 폭 0 수렴). */
function degeneratePiece(
station: StationPieces,
@@ -334,6 +377,9 @@ function degeneratePiece(
export function buildCorridor(
crossSections: CrossSection[],
routePoints: RoutePoint[],
/** 종단 계획선 샘플 — 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함)
* 따라가게 한다(2026-08-23 사용자: 종단 방향을 더 부드럽게). */
designSamples?: Array<{ chainage_m: number; elevation_m: number }>,
subdivideStepM: number = SUBDIVIDE_STEP_M,
): CorridorBuildResult | null {
const stations = crossSections
@@ -343,6 +389,8 @@ export function buildCorridor(
if (stations.length < 2) return null;
const sampler = buildPolylineSampler(routePoints);
// 종단 계획선 보간기 — 없으면 측점 간 직선(구 동작)으로 떨어진다.
const profileZ = buildProfileSampler(designSamples);
// 등장하는 (kind,side) 전체 — 리본 목록 확정.
const keys = new Set<string>();
stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key)));
@@ -376,13 +424,36 @@ export function buildCorridor(
y: s0.center.y + (s1.center.y - s0.center.y) * t,
});
const left = slerpLeft(s0.left, s1.left, t);
// 종단 보정 — 측점 사이를 직선으로 이으면 종단곡선이 각진다. 계획선 실제
// 표고와 직선 보간값의 차이만큼 단면을 통째로 올린다(도로·측구는 전부,
// 비탈은 안쪽에서 바깥으로 0까지 감쇠시켜 지반 접점을 지킨다).
const shift = profileZ
? (profileZ(chainage) ?? 0) -
((profileZ(s0.chainage_m) ?? 0) +
((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t)
: 0;
const sections = new Map<string, OffsetPoint[]>();
keys.forEach((key) => {
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
// 한쪽 측점에 없는 조각은 road edge로 축퇴시켜 리본 폭이 0으로 수렴(전이 구간).
const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side);
const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side);
sections.set(key, lerpPoints(pa, pb, t));
const hasA = s0.pieces.has(key);
const hasB = s1.pieces.has(key);
// 양쪽 다 없으면 그 구간에는 조각 자체가 없다 — 리본을 끊어 없는 자리에
// 서피스가 생기지 않게 한다(2026-08-23 사용자 지적: 측구 없는 구간).
if (!hasA && !hasB) return;
// 측구는 한쪽에만 있으면 **측점 사이 중간에서 끊는다**(2026-08-23 사용자 확정).
// 폭 0으로 길게 수렴시키면 없는 구간까지 얇은 쐐기가 남는다. 횡단배수 연결
// 자동 계산이 생기기 전까지의 표현 규칙이다.
let points: OffsetPoint[];
if (kind === "ditch" && hasA !== hasB) {
if (hasA ? t > 0.5 : t < 0.5) return;
points = (hasA ? s0 : s1).pieces.get(key)!.map((point) => ({ ...point }));
} else {
const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side);
const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side);
points = lerpPoints(pa, pb, t);
}
if (shift !== 0) applyProfileShift(points, kind, side, shift);
sections.set(key, points);
});
const lerpOuter = (a: OffsetPoint, b: OffsetPoint): OffsetPoint => ({
offset_m: a.offset_m + (b.offset_m - a.offset_m) * t,
@@ -410,22 +481,38 @@ export function buildCorridor(
keys.forEach((key) => {
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
const colCount = PIECE_COLS[kind];
const chainages: number[] = [];
const positions = new Float32Array(rows.length * colCount * 3);
let cursor = 0;
rows.forEach((row) => {
chainages.push(row.chainage_m);
// sections는 프레임마다 전 키를 채우므로(축퇴 포함) 항상 존재한다.
const points = row.sections.get(key)!;
points.forEach((point) => {
const [x, y, z] = toModel(row, point);
positions[cursor] = x;
positions[cursor + 1] = y;
positions[cursor + 2] = z;
cursor += 3;
});
});
ribbons.push({ kind, side, colCount, chainages, positions });
// 조각이 있는 **연속 구간**마다 리본을 따로 만든다 — 없는 구간을 폭 0으로
// 이어 붙이면 측구가 없는 자리에도 서피스·윤곽선이 남는다(2026-08-23 수정).
let start = -1;
const flush = (endExclusive: number): void => {
if (start < 0 || endExclusive - start < 2) {
start = -1;
return;
}
const chainages: number[] = [];
const positions = new Float32Array((endExclusive - start) * colCount * 3);
let cursor = 0;
for (let row = start; row < endExclusive; row += 1) {
chainages.push(rows[row].chainage_m);
rows[row].sections.get(key)!.forEach((point) => {
const [x, y, z] = toModel(rows[row], point);
positions[cursor] = x;
positions[cursor + 1] = y;
positions[cursor + 2] = z;
cursor += 3;
});
}
ribbons.push({ kind, side, colCount, chainages, positions });
start = -1;
};
for (let row = 0; row < rows.length; row += 1) {
if (rows[row].sections.has(key)) {
if (start < 0) start = row;
} else {
flush(row);
}
}
flush(rows.length);
});
const outline: CorridorBuildResult["outline"] = { chainages: [], left: [], right: [] };
+52 -19
View File
@@ -302,8 +302,22 @@ export function clipGeometry(
// 코리도는 지형 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를
// 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만
// 새 정점으로 뒤에 덧붙인다.
const px = position.array as ArrayLike<number>;
const cx = color ? (color.array as ArrayLike<number>) : null;
// 버퍼는 count*3보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히
// 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다
// (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인).
const px = (position.array as Float32Array).subarray(0, position.count * 3);
// 정점색은 RGB(3)일 수도 RGBA(4)일 수도 있다 — trimesh GLB는 RGBA로 내보낸다.
// stride를 고정하면 색이 어긋나 지형이 새까맣게 보인다(2026-08-23 화면 검증).
const colorSize = color?.itemSize ?? 3;
// 정점색 배열은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 그대로** 복사하고
// normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면
// 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증).
const cx = color
? (color.array as unknown as { subarray(a: number, b: number): ArrayLike<number> }).subarray(
0,
color.count * colorSize,
)
: null;
const keptIndices: number[] = [];
const extraPositions: number[] = [];
const extraColors: number[] = [];
@@ -323,17 +337,11 @@ export function clipGeometry(
px[ic * 3 + 2],
]),
color: cx
? Float64Array.from([
cx[ia * 3],
cx[ia * 3 + 1],
cx[ia * 3 + 2],
cx[ib * 3],
cx[ib * 3 + 1],
cx[ib * 3 + 2],
cx[ic * 3],
cx[ic * 3 + 1],
cx[ic * 3 + 2],
])
? Float64Array.from(
[ia, ib, ic].flatMap((v) =>
Array.from({ length: colorSize }, (_unused, c) => cx[v * colorSize + c]),
),
)
: null,
});
/** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */
@@ -341,7 +349,9 @@ export function clipGeometry(
for (let v = 0; v < 3; v += 1) {
keptIndices.push(baseCount + extraPositions.length / 3);
extraPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]);
if (cx) extraColors.push(tri.color![v * 3], tri.color![v * 3 + 1], tri.color![v * 3 + 2]);
if (cx) {
for (let c = 0; c < colorSize; c += 1) extraColors.push(tri.color![v * colorSize + c]);
}
}
};
@@ -404,14 +414,37 @@ export function clipGeometry(
positions.set(px, 0);
positions.set(extraPositions, baseCount * 3);
clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3));
if (cx) {
const colors = new Float32Array(baseCount * 3 + extraColors.length);
if (color && cx) {
const source = color.array as unknown as {
constructor: new (length: number) => { set(a: ArrayLike<number>, o?: number): void };
};
const colors = new source.constructor(baseCount * colorSize + extraColors.length);
colors.set(cx, 0);
colors.set(extraColors, baseCount * 3);
clipped.setAttribute("color", new THREE.BufferAttribute(colors, 3));
// 정수 배열이면 보간색 소수부가 잘린다 — 반올림해 담는다.
colors.set(
color.normalized ? extraColors.map((value) => Math.round(value)) : extraColors,
baseCount * colorSize,
);
clipped.setAttribute(
"color",
new THREE.BufferAttribute(colors as unknown as THREE.TypedArray, colorSize, color.normalized),
);
}
clipped.setIndex(keptIndices);
clipped.computeVertexNormals();
// 법선은 원본이 가진 것만 물려받는다. 원본에 법선이 없으면(GLB가 안 실어 보내는
// 경우) **우리도 만들지 않는다** — 직접 계산하면 감기 방향이 원본과 달라져 지형이
// 새까맣게 보였다(2026-08-23 화면 검증). 없을 때의 음영은 THREE가 알아서 만든다.
const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined;
if (normal && normal.count >= baseCount) {
const normals = new Float32Array(baseCount * 3 + extraPositions.length);
normals.set((normal.array as Float32Array).subarray(0, baseCount * 3), 0);
for (let i = 0; i < extraPositions.length; i += 3) {
normals[baseCount * 3 + i] = 0;
normals[baseCount * 3 + i + 1] = 1;
normals[baseCount * 3 + i + 2] = 0;
}
clipped.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
}
return clipped;
}
+34 -6
View File
@@ -180,6 +180,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let currentSectionDetail: SectionDetailResponse | null = null;
/** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */
let corridorRefreshTimer = 0;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
let restoring = true;
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
@@ -261,7 +263,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onReset: () => void resetDesign(),
onContourApply: (interval) => void applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onCorridorVisible: viewer.setCorridorVisible,
onCorridorVisible: (visible) => {
corridorVisible = visible;
viewer.setCorridorVisible(visible);
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
},
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
@@ -389,12 +396,33 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19).
latest?.route_points ?? [],
);
// 예상형상이 켜져 있으면 측점 바를 **계획고** 기준으로 올린다 — 원지반 위에
// 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시).
const designAt = (chainageM: number): number | null => {
if (!corridorVisible) return null;
const samples = detail.longitudinal.design_profiles?.[0]?.samples;
if (!samples?.length) return null;
let best = samples[0];
for (const sample of samples) {
if (Math.abs(sample.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) {
best = sample;
}
}
return best.elevation_m;
};
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
const withUphill = [...regular, ...injected].map((station) => ({
...station,
uphill_side:
uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null,
}));
const withUphill = [...regular, ...injected].map((station) => {
const design = designAt(station.chainage_m);
return {
...station,
center_z:
design !== null && station.center_z !== null
? Math.max(station.center_z, design)
: station.center_z,
uphill_side:
uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null,
};
});
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
}
+2 -1
View File
@@ -223,7 +223,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
);
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";
+46 -1
View File
@@ -22,6 +22,43 @@ import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38;
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
let total = 0;
root?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const index = child.geometry.getIndex();
const position = child.geometry.getAttribute("position");
total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3);
});
return total;
}
/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */
function describeSurface(root: THREE.Object3D | null): unknown {
const out: unknown[] = [];
root?.traverse((child) => {
if (!(child instanceof THREE.Mesh) || out.length >= 2) return;
const geometry = child.geometry;
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined;
const material = (
Array.isArray(child.material) ? child.material[0] : child.material
) as THREE.MeshLambertMaterial;
out.push({
type: material?.type,
vertexColors: material?.vertexColors,
matColor: material?.color?.getHexString(),
side: material?.side,
colorSize: color?.itemSize,
color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null,
normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null,
hasNormal: Boolean(normal),
});
});
return out;
}
function disposeObject(object: THREE.Object3D | null): void {
object?.traverse((child) => {
if (
@@ -379,7 +416,8 @@ export function createRouteViewer(): RouteViewer {
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
let surfaceGrayscale = false;
// 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다.
let surfaceGrayscale = true;
function applySurfaceGrayscale(): void {
[terrain, clippedTerrain].forEach((target) => applyGrayscaleTo(target));
}
@@ -486,6 +524,13 @@ export function createRouteViewer(): RouteViewer {
disposeClippedTerrain();
clippedTerrain = clipped;
scene.add(clipped);
// 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다.
(window as unknown as { __corridorClip?: unknown }).__corridorClip = {
source: countTriangles(terrain),
clipped: countTriangles(clipped),
detail: describeSurface(clipped),
sourceDetail: describeSurface(terrain),
};
applyGrayscaleTo(clipped); // 흑백 토글 상태 유지.
applyCorridorVisibility();
});