feat(B05): 3D 예상형상 새 방식 — 지형 메시 높이만 설계면으로 옮기는 변형 코어 · 새/옛 전환 단추(115-1)

- 정점 → (행 · 편거리) 역투영(행 중심 격자 + 이웃 행 걷기) · z' = clamp(지형, 성토선, 절토선)
- 비탈 끝(첫 지반 교차점) 밖은 원지반 · 단면 밖 마지막 구배 연장 · 측점 사이 제어점 보간
- 노면부 리본 유지 · 지형은 노면부 끝에서 자르고 경계 z = 앵커 z · 사본에만 · XY 고정
- 색 = 높이 변화 부호(절토 · 성토 색 · 빗금 없음 · 흑백 지형에서도 유지)
- 3D 보기 줄 「새 방식」 단추(기본 켬 · pref 등록) — 옛 방식 코드는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 20:14:08 +09:00
co-authored by Claude Opus 5.5
parent 422b5d41ca
commit 01c2a5983b
10 changed files with 930 additions and 6 deletions
+11 -2
View File
@@ -236,6 +236,15 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P
await writeCachedBytes(projectId, url, bytes);
}
/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
function withSections(
build: CorridorBuildResult | null,
detail: SectionDetailResponse,
): CorridorBuildResult | null {
if (build) build.sections = detail.cross_sections;
return build;
}
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
export function refreshCorridor(
@@ -249,7 +258,7 @@ export function refreshCorridor(
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(build))
.then((build) => viewer.setCorridor(withSections(build, detail)))
.catch(() => viewer.setCorridor(null));
}
@@ -269,7 +278,7 @@ export function loadCorridorIfFresh(
if (!routeId) return Promise.resolve(false);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false)
.then((build) => {
if (build) viewer.setCorridor(build);
if (build) viewer.setCorridor(withSections(build, detail));
return build !== null;
})
.catch(() => false);
@@ -7,6 +7,7 @@
* (`... from "./B05_Profile_UI_Corridor_Build"`)는 전부 유효하다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station";
import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures";
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
@@ -96,4 +97,6 @@ export interface CorridorBuildResult {
planCurves?: PlanCurve[];
/** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */
cutWalls?: CorridorCutWall[];
/** 측점 단면 — 새 방식 지형 변형이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */
sections?: CrossSection[];
}
@@ -0,0 +1,385 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Deform.ts
* 3D 예상형상 **새 방식** — 지형 메시 정점의 높이(z)만 설계면으로 옮기는 변형 코어
* (계획서 115-1 · 검토 `docs/raw/요구_1008_4/검토_B05_3D_메시변형.md` 3절).
*
* 순수 기하 — Three.js 없음. 지형 높이는 부르는 쪽이 `groundAt`(모델 좌표)으로 준다.
*
* 규칙:
* · 행 = 코리도 빌드의 노면부 리본(차도 · 노견 · 측구) 행. 노면부 바깥 끝 = 앵커.
* · 정점 → (행 · 편거리) 역투영 — 0.2 m 행 중심 격자 색인 + 이웃 행 걷기.
* · 노면부 바깥에서 `z' = clamp(지형 z, 성토선 z, 절토선 z)`. 선 = 앵커 + 측점 사이
* 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장.
* · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반.
* · XY 는 움직이지 않는다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types";
import {
classifyStation,
pieceKey,
type OffsetPoint,
type StationPieces,
} from "./B05_Profile_UI_Corridor_Station";
import {
bracketControls,
buildPieceControls,
lerpPoints,
type PieceControl,
} from "./B05_Profile_UI_Corridor_Frames";
export type DeformSide = "left" | "right";
export type DeformKind = "cut" | "fill";
/** 비탈 끝 찾기 한계(m) · 걸음(m) — 밴드 여유(80 m) 안쪽. */
export const MAX_REACH_M = 60;
const SCAN_STEP_M = 0.25;
/** 이 안쪽 정점은 앵커 높이 그대로(노면부 끝 경계 정점). */
const BOUNDARY_EPS_M = 0.01;
/** 역투영 되짚기 허용 오차(m) — 넘으면 곡선 안쪽 모호 자리로 보고 손대지 않는다. */
const LOCATE_TOLERANCE_M = 0.5;
/** 행 중심 격자 칸(m) · 씨앗 행 간격(행 수). */
const GRID_M = 4;
const SEED_EVERY = 5;
const WALK_LIMIT = 400;
const SIDES: readonly DeformSide[] = ["left", "right"];
const KINDS: readonly DeformKind[] = ["fill", "cut"];
/** 노면부 바깥 끝 앵커 한 행 — 모델 좌표 [x, y, z]. */
export interface DeformRow {
chainage_m: number;
left: [number, number, number];
right: [number, number, number];
}
/** 비탈 단면(앵커 기준) — 바깥거리 s ≥ 0 오름차순 [s0, dz0, s1, dz1, …]. */
export type DeformProfile = Float64Array;
/** 한 행 · 한 측의 단면 출처 — 구조물 구간 고정 단면이면 연장 · 반대 종류를 막는다. */
export interface RowProfiles {
fill: DeformProfile | null;
cut: DeformProfile | null;
/** 단면 끝 너머로 늘리지 않는다(BOX 「본체 밖 원지반」). */
noExtend?: boolean;
}
/** 행 · 측마다 단면을 내주는 자리 — 115-2 구조물 단면이 이 자리를 갈아 끼운다. */
export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles;
/** 정점 하나의 역투영 결과. */
export interface DeformLocation {
row: number;
t: number;
side: DeformSide;
/** 노면부 바깥 끝에서 잰 바깥거리(m) — 음수면 노면부 안. */
outward: number;
}
export interface DeformField {
rows: DeformRow[];
locate: (x: number, y: number) => DeformLocation | null;
/** 변형된 z — 대상이 아니면 받은 z 그대로. */
deformZ: (x: number, y: number, z: number) => number;
}
/** 단면 높이 — s 가 끝을 넘으면 마지막 구배로 늘린다(`noExtend`면 null). */
export function profileZ(profile: DeformProfile, s: number, noExtend = false): number | null {
const count = profile.length / 2;
if (count < 1) return null;
if (s <= profile[0]) return profile[1];
for (let i = 1; i < count; i += 1) {
const s1 = profile[i * 2];
if (s <= s1) {
const s0 = profile[i * 2 - 2];
const span = s1 - s0;
const t = span <= 1e-9 ? 1 : (s - s0) / span;
return profile[i * 2 - 1] + (profile[i * 2 + 1] - profile[i * 2 - 1]) * t;
}
}
if (noExtend) return null;
// 마지막 구배 — 끝에서 거꾸로 짚어 길이가 있는 마디를 쓴다(`_Station.ts` trimSlopeToGround 연장과 같은 규칙).
const last = count - 1;
for (let i = last - 1; i >= 0; i -= 1) {
const run = profile[last * 2] - profile[i * 2];
if (run > 1e-3) {
const grade = (profile[last * 2 + 1] - profile[i * 2 + 1]) / run;
return profile[last * 2 + 1] + grade * (s - profile[last * 2]);
}
}
return profile[last * 2 + 1];
}
/** 상대 단면 점(편거리 · 표고, 도로측 원점) → 바깥거리 오름차순 단면. */
export function outwardProfile(
points: ReadonlyArray<OffsetPoint>,
side: DeformSide,
): DeformProfile | null {
if (points.length < 2) return null;
const sign = side === "left" ? 1 : -1;
const ordered = side === "left" ? points : [...points].reverse();
const origin = ordered[0];
const profile = new Float64Array(ordered.length * 2);
ordered.forEach((point, i) => {
profile[i * 2] = Math.max(0, (point.offset_m - origin.offset_m) * sign);
profile[i * 2 + 1] = point.elevation_m - origin.elevation_m;
});
return profile;
}
/** 빌드 노면부 리본에서 행마다 좌우 바깥 끝(측구 > 노견 > 차도)을 모은다. */
export function roadRows(build: CorridorBuildResult): DeformRow[] {
const RANK: Record<string, number> = { carriageway: 1, shoulder: 2, ditch: 3 };
const byKey = new Map<
number,
{
chainage: number;
left?: DeformRow["left"];
lr: number;
right?: DeformRow["right"];
rr: number;
}
>();
for (const ribbon of build.ribbons) {
const rank = RANK[ribbon.kind];
if (!rank || ribbon.patch) continue;
const cols = ribbon.colCount;
ribbon.chainages.forEach((chainage, row) => {
const key = Math.round(chainage * 1000);
const entry = byKey.get(key) ?? { chainage, lr: 0, rr: 0 };
const at = (col: number): [number, number, number] => {
const i = (row * cols + col) * 3;
return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]];
};
if (ribbon.side !== "right" && rank > entry.lr) {
entry.left = at(cols - 1);
entry.lr = rank;
}
if (ribbon.side !== "left" && rank > entry.rr) {
entry.right = at(0);
entry.rr = rank;
}
byKey.set(key, entry);
});
}
return [...byKey.entries()]
.sort((a, b) => a[0] - b[0])
.flatMap(([, entry]) =>
entry.left && entry.right
? [{ chainage_m: entry.chainage, left: entry.left, right: entry.right }]
: [],
);
}
/** 측점 비탈 단면으로 내는 기본 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). */
export function stationProfileSource(sections: ReadonlyArray<CrossSection>): ProfileSource {
const stations = sections
.map(classifyStation)
.filter((s): s is StationPieces => s !== null)
.sort((a, b) => a.chainage_m - b.chainage_m);
const controls = new Map<string, PieceControl[] | null>();
SIDES.forEach((side) =>
KINDS.forEach((kind) => {
const key = pieceKey(kind, side);
controls.set(
key,
buildPieceControls(stations, side, (station) => station.slopeRelative.get(key)),
);
}),
);
const at = (side: DeformSide, kind: DeformKind, chainageM: number): DeformProfile | null => {
// 그 측에 그 종류가 한 번도 없으면 반대 측 단면을 빌린다 — 없으면 앵커 옆이 한 삼각형 비탈로 떨어진다.
const other: DeformSide = side === "left" ? "right" : "left";
const own = controls.get(pieceKey(kind, side));
const list = own ?? controls.get(pieceKey(kind, other));
if (!list) return null;
const { a, b, t } = bracketControls(list, chainageM);
const points = a === b ? a.points : lerpPoints(a.points, b.points, t);
return outwardProfile(points, own ? side : other);
};
return (chainageM, side) => ({
fill: at(side, "fill", chainageM),
cut: at(side, "cut", chainageM),
});
}
/**
* 변형 장 만들기. 노면부 행이 2개 미만이면 null.
* `groundAt` = 원지반 높이(모델 좌표) — 비탈 끝(첫 지반 교차점) 찾기에만 쓴다.
*/
export function buildDeformField(
build: CorridorBuildResult,
source: ProfileSource,
groundAt: (x: number, y: number) => number | null,
): DeformField | null {
const rows = roadRows(build);
const n = rows.length;
if (n < 2) return null;
const mx = new Float64Array(n);
const my = new Float64Array(n);
const ux = new Float64Array(n);
const uy = new Float64Array(n);
const hw = new Float64Array(n);
/** [행 × 측 × 종류] 단면 · 비탈 끝 거리. 측 = 0 좌 · 1 우, 종류 = 0 성토 · 1 절토. */
const profiles: Array<DeformProfile | null> = new Array(n * 4).fill(null);
const catches = new Float64Array(n * 4);
const noExtend = new Uint8Array(n * 2);
let reachMax = 0;
rows.forEach((row, r) => {
const dx = row.left[0] - row.right[0];
const dy = row.left[1] - row.right[1];
const width = Math.hypot(dx, dy) || 1e-9;
mx[r] = (row.left[0] + row.right[0]) / 2;
my[r] = (row.left[1] + row.right[1]) / 2;
ux[r] = dx / width;
uy[r] = dy / width;
hw[r] = width / 2;
SIDES.forEach((side, si) => {
const anchor = side === "left" ? row.left : row.right;
const sign = side === "left" ? 1 : -1;
const shapes = source(row.chainage_m, side);
noExtend[r * 2 + si] = shapes.noExtend ? 1 : 0;
KINDS.forEach((kind, ki) => {
const profile = shapes[kind];
profiles[r * 4 + si * 2 + ki] = profile;
if (!profile) return;
// 비탈 끝 = 앵커에서 바깥으로 걸어 지반과 처음 만나는 자리. 성토는 지반이 선 위로,
// 절토는 아래로 넘어가는 곳. 앵커에서 이미 반대편이면 그 종류는 이 행에 없다(0).
const beyond = (s: number): number | null => {
const line = profileZ(profile, s, shapes.noExtend);
if (line === null) return null;
const ground = groundAt(anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s);
if (ground === null) return null;
const gap = anchor[2] + line - ground;
return kind === "fill" ? gap : -gap;
};
let previous = beyond(0);
let reach = 0;
if (previous !== null && previous > 0) {
for (let s = SCAN_STEP_M; s <= MAX_REACH_M + 1e-9; s += SCAN_STEP_M) {
const now = beyond(s);
if (now === null) {
reach = s - SCAN_STEP_M;
break;
}
if (now <= 0) {
reach = s - SCAN_STEP_M + (SCAN_STEP_M * previous) / (previous - now);
break;
}
previous = now;
reach = s;
}
}
catches[r * 4 + si * 2 + ki] = reach;
if (reach > reachMax) reachMax = reach;
});
});
});
// 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다.
let maxHalf = 0;
hw.forEach((value) => (maxHalf = Math.max(maxHalf, value)));
const radius = maxHalf + reachMax + GRID_M;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let r = 0; r < n; r += 1) {
minX = Math.min(minX, mx[r]);
maxX = Math.max(maxX, mx[r]);
minY = Math.min(minY, my[r]);
maxY = Math.max(maxY, my[r]);
}
minX -= radius;
minY -= radius;
const cols = Math.max(1, Math.ceil((maxX + radius - minX) / GRID_M));
const gridRows = Math.max(1, Math.ceil((maxY + radius - minY) / GRID_M));
const seed = new Int32Array(cols * gridRows).fill(-1);
const seedDistance = new Float64Array(cols * gridRows).fill(Infinity);
const span = Math.ceil(radius / GRID_M);
const seeds: number[] = [];
for (let r = 0; r < n; r += SEED_EVERY) seeds.push(r);
if (seeds[seeds.length - 1] !== n - 1) seeds.push(n - 1);
for (const r of seeds) {
const c0 = Math.floor((mx[r] - minX) / GRID_M);
const g0 = Math.floor((my[r] - minY) / GRID_M);
for (let g = Math.max(0, g0 - span); g <= Math.min(gridRows - 1, g0 + span); g += 1) {
for (let c = Math.max(0, c0 - span); c <= Math.min(cols - 1, c0 + span); c += 1) {
const d = Math.hypot(minX + (c + 0.5) * GRID_M - mx[r], minY + (g + 0.5) * GRID_M - my[r]);
if (d > radius || d >= seedDistance[g * cols + c]) continue;
seedDistance[g * cols + c] = d;
seed[g * cols + c] = r;
}
}
}
const along = (r: number, x: number, y: number): number =>
(x - mx[r]) * uy[r] - (y - my[r]) * ux[r];
const lateral = (r: number, x: number, y: number): number =>
(x - mx[r]) * ux[r] + (y - my[r]) * uy[r];
// ponytail: 칸의 가장 가까운 씨앗 하나로 걷는다 — 헤어핀 두 가지가 한 칸에 겹치면 칸 중심에
// 가까운 쪽(대개 편거리 작은 쪽)을 탄다. 어긋나면 씨앗 둘을 견주는 쪽으로 넓힌다.
function locate(x: number, y: number): DeformLocation | null {
const c = Math.floor((x - minX) / GRID_M);
const g = Math.floor((y - minY) / GRID_M);
if (c < 0 || g < 0 || c >= cols || g >= gridRows) return null;
let k = seed[g * cols + c];
if (k < 0) return null;
let steps = 0;
while (k > 0 && along(k, x, y) < 0 && steps < WALK_LIMIT) {
k -= 1;
steps += 1;
}
while (k + 1 < n && along(k + 1, x, y) >= 0 && steps < WALK_LIMIT) {
k += 1;
steps += 1;
}
if (k + 1 >= n) return null;
const a0 = along(k, x, y);
const a1 = along(k + 1, x, y);
if (a0 < 0 || a1 >= 0) return null;
const t = a0 / (a0 - a1);
const o = lateral(k, x, y) * (1 - t) + lateral(k + 1, x, y) * t;
// 되짚기 — 보간 행 위 그 편거리 자리가 정점과 같아야 한다(곡선 안쪽 너머는 버린다).
const cx = mx[k] + (mx[k + 1] - mx[k]) * t;
const cy = my[k] + (my[k + 1] - my[k]) * t;
const vx = ux[k] + (ux[k + 1] - ux[k]) * t;
const vy = uy[k] + (uy[k + 1] - uy[k]) * t;
const norm = Math.hypot(vx, vy) || 1;
if (Math.hypot(cx + (vx / norm) * o - x, cy + (vy / norm) * o - y) > LOCATE_TOLERANCE_M) {
return null;
}
const half = hw[k] + (hw[k + 1] - hw[k]) * t;
return { row: k, t, side: o >= 0 ? "left" : "right", outward: Math.abs(o) - half };
}
/** 한 행 기준 변형 높이 — 비탈 끝 안에서만 성토선 위로 · 절토선 아래로 맞춘다. */
function rowZ(r: number, si: number, s: number, z: number): number {
const anchor = si === 0 ? rows[r].left : rows[r].right;
if (s <= BOUNDARY_EPS_M) return anchor[2];
let out = z;
const free = noExtend[r * 2 + si] === 1;
KINDS.forEach((kind, ki) => {
const slot = r * 4 + si * 2 + ki;
const profile = profiles[slot];
if (!profile || s >= catches[slot]) return;
const rel = profileZ(profile, s, free);
if (rel === null) return;
const line = anchor[2] + rel;
if (kind === "fill" ? out < line : out > line) out = line;
});
return out;
}
function deformZ(x: number, y: number, z: number): number {
const at = locate(x, y);
if (!at || at.outward > reachMax) return z;
const si = at.side === "left" ? 0 : 1;
const s = Math.max(0, at.outward);
return rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t;
}
return { rows, locate, deformZ };
}
@@ -0,0 +1,156 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Deform_Terrain.ts
* 새 방식 지형 — 밴드 사본을 노면부 바깥선에서 자르고(`clipGeometry`) 사본 정점의
* 높이만 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1).
*
* 원본 · 분할본 정점 버퍼는 건드리지 않는다 — `clipGeometry` 가 새 위치 배열을 만들고,
* 바뀐 것이 없을 땐 여기서 복제한다. [예상형상] OFF 면 뷰어가 원본을 그대로 보인다.
* 법선은 새로 만들지 않는다(감기 방향 뒤집혀 검게 된 전례 · `_Clip.ts`).
* 색 = 높이 변화 부호 — 절토 · 성토 색(코리도 리본과 같은 색), 빗금 없음.
* ========================================================================== */
import * as THREE from "three";
import type { ModelBounds } from "./B05_Profile_UI_Markers";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types";
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { clipGeometry } from "./B05_Profile_UI_Corridor_Clip";
import { CellRegion, scenePlanMapper, type PlanRegion } from "./B05_Profile_UI_Corridor_Region";
import {
buildDeformField,
stationProfileSource,
type DeformField,
type DeformRow,
} from "./B05_Profile_UI_Corridor_Deform";
/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */
const CUT_COLOR = new THREE.Color(0xb5179e);
const FILL_COLOR = new THREE.Color(0x06b6d4);
/** 이보다 작게 움직인 정점은 원지반 색 그대로(m). */
const TINT_MIN_M = 0.01;
/** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */
class RoadStrip extends CellRegion {
constructor(rows: ReadonlyArray<DeformRow>, bounds: ModelBounds) {
super();
const toScene = scenePlanMapper(bounds);
const left = rows.map((row) => toScene(row.left[0], row.left[1]));
const right = rows.map((row) => toScene(row.right[0], row.right[1]));
for (let i = 0; i < rows.length - 1; i += 1) {
this.addCell(left[i], right[i], right[i + 1], left[i + 1]);
this.addSegment(left[i], left[i + 1]);
this.addSegment(right[i], right[i + 1]);
}
this.addSegment(left[0], right[0]);
this.addSegment(left[rows.length - 1], right[rows.length - 1]);
this.buildIndex();
}
}
/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */
export interface DeformStats {
rows: number;
moved: number;
raised: number;
lowered: number;
maxRaiseM: number;
maxCutM: number;
}
/**
* 사본 geometry 정점 높이를 옮기고 색을 칠한다. 씬 좌표(Y-up) ↔ 모델 좌표는 뷰어 규약.
* 칠한 정점은 `userData.keepColor` 에 표시해 흑백 지형 토글이 덮지 않게 한다.
*/
function deformGeometry(
geometry: THREE.BufferGeometry,
field: DeformField,
bounds: ModelBounds,
stats: DeformStats,
): Uint8Array {
const ox = (bounds.x[0] + bounds.x[1]) / 2;
const oy = (bounds.y[0] + bounds.y[1]) / 2;
const oz = (bounds.z[0] + bounds.z[1]) / 2;
const position = geometry.getAttribute("position") as THREE.BufferAttribute;
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
const keep = new Uint8Array(position.count);
for (let v = 0; v < position.count; v += 1) {
const sceneY = position.getY(v);
const z = sceneY + oz;
const next = field.deformZ(position.getX(v) + ox, -position.getZ(v) + oy, z);
const delta = next - z;
if (Math.abs(delta) < TINT_MIN_M) continue;
position.setY(v, next - oz);
stats.moved += 1;
if (delta > 0) {
stats.raised += 1;
stats.maxRaiseM = Math.max(stats.maxRaiseM, delta);
} else {
stats.lowered += 1;
stats.maxCutM = Math.max(stats.maxCutM, -delta);
}
if (color) {
const tint = delta > 0 ? FILL_COLOR : CUT_COLOR;
color.setXYZ(v, tint.r, tint.g, tint.b);
keep[v] = 1;
}
}
position.needsUpdate = true;
if (color) color.needsUpdate = true;
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
return keep;
}
/**
* 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 잘라 변형한다. 변형 장을 못 세우면
* (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다.
*/
export function deformTerrain(
split: TerrainBandSplit,
build: CorridorBuildResult,
bounds: ModelBounds,
heightIndex: TerrainHeightIndex,
extraRegions: ReadonlyArray<PlanRegion> = [],
): { root: THREE.Object3D; stats: DeformStats } | null {
if (!build.sections?.length) return null;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
const oy = (bounds.y[0] + bounds.y[1]) / 2;
const oz = (bounds.z[0] + bounds.z[1]) / 2;
const groundAt = (x: number, y: number): number | null => {
const height = heightIndex.heightAt(x - ox, -(y - oy));
return height === null ? null : height + oz;
};
const field = buildDeformField(build, stationProfileSource(build.sections), groundAt);
if (!field) return null;
const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds), ...extraRegions];
const stats: DeformStats = {
rows: field.rows.length,
moved: 0,
raised: 0,
lowered: 0,
maxRaiseM: 0,
maxCutM: 0,
};
const root = new THREE.Group();
root.name = "terrain-deformed";
split.near.forEach((part) => {
if (part.points) {
// 포인트클라우드는 변형 대상이 아니다 — 분할본 그대로 얹는다.
const points = new THREE.Points(part.geometry, part.material as THREE.Material);
points.userData.owned = false;
root.add(points);
return;
}
const geometry = clipGeometry(part.geometry, regions) ?? part.geometry.clone();
const mesh = new THREE.Mesh(geometry, part.material);
mesh.userData.owned = true;
mesh.userData.keepColor = deformGeometry(geometry, field, bounds, stats);
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, stats };
}
+3
View File
@@ -5,6 +5,7 @@
* ========================================================================== */
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
import { corridorDeformOn, setCorridorDeform } from "./B05_Profile_UI_Viewer_Corridor";
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
export interface ViewControlCallbacks {
@@ -62,6 +63,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
toggleButton("지표면", true, callbacks.onSurfaceVisible),
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
toggleButton("예상형상", true, callbacks.onCorridorVisible),
// 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치).
toggleButton("새 방식", corridorDeformOn(), setCorridorDeform),
toggleButton("등고선", true, callbacks.onContoursVisible),
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
+3
View File
@@ -399,8 +399,11 @@ export function createRouteViewer(): RouteViewer {
if (!child.userData.originalColors) child.userData.originalColors = target.slice();
const original = child.userData.originalColors as Float32Array;
const stride = color.itemSize;
// 새 방식 절성토 색(115-1)은 흑백에서도 남긴다.
const keep = child.userData.keepColor as Uint8Array | undefined;
if (surfaceGrayscale) {
for (let i = 0; i + 2 < original.length; i += stride) {
if (keep?.[i / stride]) continue;
const gray = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2];
target[i] = target[i + 1] = target[i + 2] = gray;
}
+45 -4
View File
@@ -19,6 +19,33 @@ import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug";
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
/**
* 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치).
* 사용자가 두 화면을 견주는 동안만 둔다(115-3 에서 옛 방식과 함께 지운다).
*/
const deformListeners = new Set<(on: boolean) => void>();
export function corridorDeformOn(): boolean {
return readStateRaw("corridor-deform") !== "0";
}
export function setCorridorDeform(on: boolean): void {
writeStateRaw("corridor-deform", on ? "1" : "0");
deformListeners.forEach((listener) => listener(on));
}
/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */
function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
return {
...build,
ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"),
planCurves: [],
cutWalls: [],
};
}
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
@@ -104,6 +131,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
let bandSplit: TerrainBandSplit | null = null;
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
let deformOn = corridorDeformOn();
const onDeform = (on: boolean): void => {
deformOn = on;
if (corridorBuild) set(corridorBuild);
};
deformListeners.add(onDeform);
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
function applyCorridorVisibility(): void {
@@ -251,7 +284,11 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const split = ensureBandSplit(buildAtSchedule);
if (!split) return;
const clipped = clipTerrain(split, buildAtSchedule, bounds);
const index = deformOn ? deps.heightIndex() : null;
const deformed = index ? deformTerrain(split, buildAtSchedule, bounds, index) : null;
(window as unknown as { __corridorDeform?: unknown }).__corridorDeform =
deformed?.stats ?? null;
const clipped = deformed?.root ?? clipTerrain(split, buildAtSchedule, bounds);
disposeClippedTerrain();
clippedTerrain = clipped;
scene.add(clipped);
@@ -274,9 +311,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
setCorridorBuildSummary(build);
const bounds = deps.bounds();
if (build && bounds) {
snapCorridorEdges(build);
attachPatchSkirts(build);
corridorGroup = createCorridorGroup(build, bounds);
// 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다.
if (!deformOn) {
snapCorridorEdges(build);
attachPatchSkirts(build);
}
corridorGroup = createCorridorGroup(deformOn ? roadOnly(build) : build, bounds);
scene.add(corridorGroup);
deps.onGroup();
}
@@ -321,6 +361,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
group: () => corridorGroup,
clipped: () => clippedTerrain,
dispose() {
deformListeners.delete(onDeform);
corridorBuild = null; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();