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