Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -66,6 +66,8 @@ export const STATE_REGISTRY = {
|
||||
"m02-card-pills": { bucket: "pref", scope: "global" },
|
||||
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
|
||||
"m01-logic-panel": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 예상형상 그리기 — "1" 새 방식(지형 변형 · 기본) · "0" 옛 방식(115-1 비교용) */
|
||||
"corridor-deform": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
|
||||
@@ -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,456 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 구조물 구간 단면(115-2) — 소유 측점 실루엣의 상부 포락선(앵커 기준 상대 · 측점 단면과
|
||||
* 같은 꼴 · 같은 열 수). 구간(전/후 몫) 안은 이 단면 그대로, 밖은 이웃 측점까지 테이퍼.
|
||||
*/
|
||||
export interface StructureSlope {
|
||||
chainage_m: number;
|
||||
side: DeformSide;
|
||||
kind: DeformKind;
|
||||
span: { beforeM: number; afterM: number };
|
||||
points: OffsetPoint[];
|
||||
/** 실루엣이 구조물에서 끝난다(BOX) — 구간 안은 단면 끝 너머를 원지반으로 둔다. */
|
||||
endsAtStructure: boolean;
|
||||
}
|
||||
|
||||
/** 성토 금지 고리(115-2 · 날개 안쪽 유로 · 에이프런) — 안쪽 정점은 원지반(또는 천장) 위로 못 올린다. */
|
||||
export interface DeformLimit {
|
||||
/** 모델 좌표 닫힌 고리. */
|
||||
loop: Array<[number, number]>;
|
||||
/** 바닥판 상단 등 — 원지반이 이보다 낮으면 여기까지는 올린다. null 이면 원지반. */
|
||||
ceiling: number | null;
|
||||
}
|
||||
|
||||
/** 정점 하나의 역투영 결과. */
|
||||
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>,
|
||||
slopes: ReadonlyArray<StructureSlope> = [],
|
||||
): ProfileSource {
|
||||
const stations = sections
|
||||
.map(classifyStation)
|
||||
.filter((s): s is StationPieces => s !== null)
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const slopeOf = (station: StationPieces, side: DeformSide, kind: DeformKind) =>
|
||||
slopes.find(
|
||||
(slope) =>
|
||||
slope.side === side &&
|
||||
slope.kind === kind &&
|
||||
Math.abs(slope.chainage_m - station.chainage_m) < 1e-6,
|
||||
);
|
||||
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) => slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(key),
|
||||
(station) => slopeOf(station, side, kind)?.span,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
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) => {
|
||||
const fill = at(side, "fill", chainageM);
|
||||
const cut = at(side, "cut", chainageM);
|
||||
const fixed = slopes.find(
|
||||
(slope) =>
|
||||
slope.side === side &&
|
||||
chainageM >= slope.chainage_m - slope.span.beforeM - 1e-9 &&
|
||||
chainageM <= slope.chainage_m + slope.span.afterM + 1e-9,
|
||||
);
|
||||
if (!fixed) return { fill, cut };
|
||||
return {
|
||||
fill: fixed.kind === "fill" ? fill : null,
|
||||
cut: fixed.kind === "cut" ? cut : null,
|
||||
noExtend: fixed.endsAtStructure,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** 점이 고리 안인가(광선 투사). */
|
||||
function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
||||
let hit = false;
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
||||
const [xi, yi] = loop[i];
|
||||
const [xj, yj] = loop[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변형 장 만들기. 노면부 행이 2개 미만이면 null.
|
||||
* `groundAt` = 원지반 높이(모델 좌표) — 비탈 끝(첫 지반 교차점) 찾기에만 쓴다.
|
||||
*/
|
||||
export function buildDeformField(
|
||||
build: CorridorBuildResult,
|
||||
source: ProfileSource,
|
||||
groundAt: (x: number, y: number) => number | null,
|
||||
limits: ReadonlyArray<DeformLimit> = [],
|
||||
): 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);
|
||||
let out = rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t;
|
||||
for (const limit of limits) {
|
||||
if (out > z && insideLoop(limit.loop, x, y)) {
|
||||
out = Math.min(out, Math.max(z, limit.ceiling ?? z));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return { rows, locate, deformZ };
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Deform_Structure.ts
|
||||
* 새 방식 지형 변형의 **구조물 마스크**(계획서 115-2) — 순수 기하.
|
||||
*
|
||||
* · 구조물 구간 단면 = 소유 측점 실루엣(`_Station_Structure.ts`)의 상부 포락선.
|
||||
* 구간 안은 고정, 구간 끝 → 이웃 측점까지 제어점 테이퍼(`_Frames.ts`).
|
||||
* · 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(`_Plan_Footprint.ts`). 솔리드가 메운다.
|
||||
* · 성토 금지 고리 = 날개 안쪽 유로 · 에이프런 — 원지반(세월교는 바닥판 상단) 위로 안 올린다.
|
||||
*
|
||||
* 구조물 솔리드(`_Structures*.ts`)는 읽기만 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import {
|
||||
classifyStation,
|
||||
PIECE_COLS,
|
||||
pieceKey,
|
||||
resample,
|
||||
type OffsetPoint,
|
||||
type StationPieces,
|
||||
} from "./B05_Profile_UI_Corridor_Station";
|
||||
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
|
||||
import {
|
||||
structureFootprintLoops,
|
||||
type StationAxis,
|
||||
} from "./B05_Profile_UI_Corridor_Plan_Footprint";
|
||||
import type {
|
||||
DeformKind,
|
||||
DeformLimit,
|
||||
DeformSide,
|
||||
StructureSlope,
|
||||
} from "./B05_Profile_UI_Corridor_Deform";
|
||||
|
||||
/**
|
||||
* 상부 포락선 — 바깥거리(`offset_m` = s ≥ 0)마다 실루엣 마디 중 가장 높은 표고.
|
||||
* 벽 앞면(거의 수직) · 다단 계단처럼 바깥으로 갔다 돌아오는 줄을 높이장 한 줄로 편다.
|
||||
*/
|
||||
export function upperEnvelope(points: ReadonlyArray<OffsetPoint>): OffsetPoint[] {
|
||||
if (points.length < 2) return points.map((point) => ({ ...point }));
|
||||
const xs = [...new Set(points.map((point) => point.offset_m))].sort((a, b) => a - b);
|
||||
return xs.map((x) => {
|
||||
let best = -Infinity;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const lo = Math.min(a.offset_m, b.offset_m);
|
||||
const hi = Math.max(a.offset_m, b.offset_m);
|
||||
if (x < lo - 1e-9 || x > hi + 1e-9) continue;
|
||||
const z =
|
||||
hi - lo < 1e-9
|
||||
? Math.max(a.elevation_m, b.elevation_m)
|
||||
: a.elevation_m +
|
||||
((b.elevation_m - a.elevation_m) * (x - a.offset_m)) / (b.offset_m - a.offset_m);
|
||||
if (z > best) best = z;
|
||||
}
|
||||
return { offset_m: x, elevation_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
/** 측점의 노면부 바깥 끝(측구 > 노견) — 행 앵커와 같은 자리. */
|
||||
function anchorOf(station: StationPieces, side: DeformSide): OffsetPoint | null {
|
||||
const piece =
|
||||
station.pieces.get(pieceKey("ditch", side)) ?? station.pieces.get(pieceKey("shoulder", side));
|
||||
if (!piece || piece.length < 2) return null;
|
||||
return side === "left" ? piece[piece.length - 1] : piece[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 실루엣 → 앵커 기준 상대 단면(측점 단면 `slopeRelative` 와 같은 꼴 · 같은 열 수).
|
||||
* 앵커 안쪽(노면부)은 잘라 내고, 앵커 자리 교차점을 첫 점으로 세운다.
|
||||
*/
|
||||
function silhouetteSlope(
|
||||
points: ReadonlyArray<OffsetPoint>,
|
||||
side: DeformSide,
|
||||
anchor: OffsetPoint,
|
||||
kind: DeformKind,
|
||||
): OffsetPoint[] | null {
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const outward = points.map((point) => ({
|
||||
offset_m: (point.offset_m - anchor.offset_m) * sign,
|
||||
elevation_m: point.elevation_m - anchor.elevation_m,
|
||||
}));
|
||||
const kept: OffsetPoint[] = [];
|
||||
outward.forEach((point, i) => {
|
||||
if (point.offset_m < 0) return;
|
||||
const previous = outward[i - 1];
|
||||
if (!kept.length && previous && previous.offset_m < 0) {
|
||||
const t = -previous.offset_m / (point.offset_m - previous.offset_m);
|
||||
kept.push({
|
||||
offset_m: 0,
|
||||
elevation_m: previous.elevation_m + (point.elevation_m - previous.elevation_m) * t,
|
||||
});
|
||||
}
|
||||
kept.push(point);
|
||||
});
|
||||
if (kept.length && kept[0].offset_m > 1e-9) kept.unshift({ offset_m: 0, elevation_m: 0 });
|
||||
const envelope = upperEnvelope(kept);
|
||||
if (envelope.length < 2 || envelope[envelope.length - 1].offset_m < 1e-3) return null;
|
||||
const sampled = resample(envelope, PIECE_COLS[kind]);
|
||||
// 측점 단면 꼴로 — 좌측은 바깥거리 그대로, 우측은 부호를 뒤집어 오름차순(도로측 원점이 끝).
|
||||
return side === "left"
|
||||
? sampled
|
||||
: sampled
|
||||
.map((point) => ({ offset_m: -point.offset_m, elevation_m: point.elevation_m }))
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/** 구조물 측점마다 그 측 실루엣을 구간 단면으로 — `stationProfileSource` 두 번째 인자. */
|
||||
export function structureSlopes(sections: ReadonlyArray<CrossSection>): StructureSlope[] {
|
||||
const slopes: StructureSlope[] = [];
|
||||
for (const section of sections) {
|
||||
const silhouettes = structureSilhouettes(section);
|
||||
if (!silhouettes.length) continue;
|
||||
const station = classifyStation(section);
|
||||
const mode = section.design?.section_mode;
|
||||
if (!station || !mode) continue;
|
||||
for (const silhouette of silhouettes) {
|
||||
const side = silhouette.side;
|
||||
const role: DeformKind =
|
||||
mode === "both_cut" || mode === (side === "left" ? "left_cut" : "right_cut")
|
||||
? "cut"
|
||||
: "fill";
|
||||
const kind = silhouette.kind === "auto" ? role : silhouette.kind;
|
||||
const anchor = anchorOf(station, side);
|
||||
if (!anchor) continue;
|
||||
const points = silhouetteSlope(silhouette.points, side, anchor, kind);
|
||||
if (!points) continue;
|
||||
slopes.push({
|
||||
chainage_m: section.chainage_m,
|
||||
side,
|
||||
kind,
|
||||
span: silhouette.span,
|
||||
points,
|
||||
endsAtStructure: silhouette.endsAtStructure === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return slopes;
|
||||
}
|
||||
|
||||
/** 측점 프레임 축. */
|
||||
function axisOf(section: CrossSection): StationAxis {
|
||||
return {
|
||||
cx: section.center_x,
|
||||
cy: section.center_y,
|
||||
leftX: section.frame.left_xy[0],
|
||||
leftY: section.frame.left_xy[1],
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 측점에 선 구조물 한 벌. */
|
||||
function setOf(
|
||||
section: CrossSection,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): CorridorStructure[] {
|
||||
return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6);
|
||||
}
|
||||
|
||||
/** 지형 구멍 고리(모델 좌표) — 구조물 풋프린트 · BOX 아령 외곽. 도로부 안쪽은 뺀다. */
|
||||
export function structureHoleLoops(
|
||||
sections: ReadonlyArray<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): Array<Array<[number, number]>> {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
for (const section of sections) {
|
||||
const set = setOf(section, structures);
|
||||
const edges = section.design?.road_edges;
|
||||
if (!set.length || !edges) continue;
|
||||
(["left", "right"] as const).forEach((side) => {
|
||||
const roadEdge = side === "left" ? edges.left.offset_m : edges.right.offset_m;
|
||||
structureFootprintLoops(set, side, roadEdge, 0, axisOf(section)).forEach((loop) =>
|
||||
loops.push(loop.map(([x, y]) => [x, y] as [number, number])),
|
||||
);
|
||||
});
|
||||
}
|
||||
return loops;
|
||||
}
|
||||
|
||||
/** 볼록 껍질(단조 사슬) — 날개 두 장의 링 중심을 감싸 날개 안쪽 유로 자리를 낸다. */
|
||||
function convexHull(points: Array<[number, number]>): Array<[number, number]> {
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (sorted.length < 3) return sorted;
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]): number =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: Array<[number, number]> = [];
|
||||
for (const point of sorted) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0)
|
||||
lower.pop();
|
||||
lower.push(point);
|
||||
}
|
||||
const upper: Array<[number, number]> = [];
|
||||
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
||||
const point = sorted[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0)
|
||||
upper.pop();
|
||||
upper.push(point);
|
||||
}
|
||||
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||
}
|
||||
|
||||
function polygonArea(loop: ReadonlyArray<[number, number]>): number {
|
||||
let area = 0;
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
||||
area += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1];
|
||||
}
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토 금지 고리 — 측마다 날개(날개벽 · 날개 에이프런) 링 중심을 감싼 볼록 껍질.
|
||||
* 천장 = 세월교 바닥판 상단(그 측 실루엣) · BOX 는 원지반.
|
||||
*/
|
||||
export function noFillLimits(
|
||||
sections: ReadonlyArray<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): DeformLimit[] {
|
||||
const limits: DeformLimit[] = [];
|
||||
for (const section of sections) {
|
||||
const set = setOf(section, structures);
|
||||
if (!set.length) continue;
|
||||
const axis = axisOf(section);
|
||||
const floors = new Map<DeformSide, number>();
|
||||
structureSilhouettes(section).forEach((silhouette) => {
|
||||
if (silhouette.floorTopZ !== undefined) floors.set(silhouette.side, silhouette.floorTopZ);
|
||||
});
|
||||
(["left", "right"] as const).forEach((side) => {
|
||||
const centers: Array<[number, number]> = [];
|
||||
for (const structure of set) {
|
||||
const rings = structure.rings;
|
||||
if (!structure.wing || structure.kind === "pipe" || !rings || rings.length < 2) continue;
|
||||
const middle = rings[Math.floor(rings.length / 2)];
|
||||
const offset = (middle.cx - axis.cx) * axis.leftX + (middle.cy - axis.cy) * axis.leftY;
|
||||
if ((offset >= 0 ? "left" : "right") !== side) continue;
|
||||
rings.forEach((ring) => centers.push([ring.cx, ring.cy]));
|
||||
}
|
||||
const loop = convexHull(centers);
|
||||
if (loop.length < 3 || polygonArea(loop) < 0.01) return;
|
||||
limits.push({ loop, ceiling: floors.get(side) ?? null });
|
||||
});
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/* =============================================================================
|
||||
* 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,
|
||||
PlanLoopRegion,
|
||||
scenePlanMapper,
|
||||
type PlanRegion,
|
||||
} from "./B05_Profile_UI_Corridor_Region";
|
||||
import {
|
||||
buildDeformField,
|
||||
stationProfileSource,
|
||||
type DeformField,
|
||||
type DeformRow,
|
||||
} from "./B05_Profile_UI_Corridor_Deform";
|
||||
import {
|
||||
noFillLimits,
|
||||
structureHoleLoops,
|
||||
structureSlopes,
|
||||
} from "./B05_Profile_UI_Corridor_Deform_Structure";
|
||||
|
||||
/** 절토 · 성토 색 — 코리도 리본 색(`_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;
|
||||
holes: 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,
|
||||
): { root: THREE.Object3D; stats: DeformStats } | null {
|
||||
const sections = build.sections;
|
||||
if (!sections?.length) return null;
|
||||
const structures = build.structures ?? [];
|
||||
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;
|
||||
};
|
||||
// 구조물 구간 = 실루엣 상부 포락선 + 이웃 측점 테이퍼 · 날개 안쪽 성토 금지(115-2).
|
||||
const source = stationProfileSource(sections, structureSlopes(sections));
|
||||
const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures));
|
||||
if (!field) return null;
|
||||
// 지형 구멍 = 노면부 띠 + 구조물 풋프린트 · BOX 아령 외곽 — 솔리드가 그 자리를 메운다.
|
||||
const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds)];
|
||||
const holes = new PlanLoopRegion(structureHoleLoops(sections, structures), bounds);
|
||||
if (!holes.isEmpty) regions.push(holes);
|
||||
const stats: DeformStats = {
|
||||
rows: field.rows.length,
|
||||
holes: regions.length - 1,
|
||||
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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/* PLAN 115 단위검증 헬퍼 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const THREE = require(path.join(ROOT, "config", "node_modules", "three"));
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request === "three") return THREE;
|
||||
// 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다.
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
|
||||
return new Proxy({}, { get: () => () => undefined });
|
||||
}
|
||||
const target = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
||||
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
|
||||
const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform"));
|
||||
const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
|
||||
const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000);
|
||||
const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5)
|
||||
const ROAD_Z = 100;
|
||||
|
||||
/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */
|
||||
function station(chainage, mode, groundZ, ratio) {
|
||||
const w = Math.abs(groundZ - ROAD_Z) * ratio;
|
||||
const pts = [
|
||||
[-30, groundZ],
|
||||
[-(HALF + w), groundZ],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[HALF + w, groundZ],
|
||||
[30, groundZ],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: chainage,
|
||||
center_y: 0,
|
||||
center_z: groundZ,
|
||||
frame: { left_xy: [0, 1] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: mode,
|
||||
road_edges: {
|
||||
left: { offset_m: HALF, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -HALF, elevation_m: ROAD_Z },
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -2.5, elevation_m: ROAD_Z },
|
||||
},
|
||||
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
ditch: { type: "none" },
|
||||
ditch_enabled: false,
|
||||
ditch_side: "left",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ROUTE = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
];
|
||||
|
||||
function fieldOf(sections, groundAt) {
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt);
|
||||
return { build, field };
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
// ① 성토 — 지반 96 · 1:1.5. 비탈 안 정점은 성토선으로 올라가고, 끝(6 m) 밖은 그대로.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const { build, field } = fieldOf(sections, () => 96);
|
||||
results.fill = {
|
||||
rows: field.rows.length,
|
||||
anchorL: field.rows[10].left.map(r4),
|
||||
left2: r4(field.deformZ(10, HALF + 2, 96)),
|
||||
right2: r4(field.deformZ(10, -HALF - 2, 96)),
|
||||
boundary: r4(field.deformZ(10, HALF, 96)),
|
||||
beyond: r4(field.deformZ(10, HALF + 9, 96)),
|
||||
// 지반이 성토선보다 높은 정점은 그대로(clamp 아래쪽만 올린다).
|
||||
above: r4(field.deformZ(10, HALF + 2, 99.5)),
|
||||
oldRibbons: build.ribbons.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ② 첫 지반 교차점 밖은 원지반 — 비탈 끝(6 m) 너머 골짜기(80)는 연장선(92)으로 메우지 않는다.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const groundAt = (x, y) => (Math.abs(y) - HALF > 10 ? 80 : 96);
|
||||
const { field } = fieldOf(sections, groundAt);
|
||||
results.valley = {
|
||||
inside: r4(field.deformZ(10, HALF + 2, 96)),
|
||||
farValley: r4(field.deformZ(10, HALF + 15, 80)),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 절토 — 지반 104 · 1:1. 비탈 안 정점은 절토선으로 내려가고 끝(4 m) 밖은 그대로.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_cut", 104, 1.0));
|
||||
const { field } = fieldOf(sections, () => 104);
|
||||
results.cut = {
|
||||
left2: r4(field.deformZ(10, HALF + 2, 104)),
|
||||
beyond: r4(field.deformZ(10, HALF + 6, 104)),
|
||||
below: r4(field.deformZ(10, HALF + 2, 101)),
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 측점 사이 보간 — 0 측점 1:1.5, 40 측점 1:2. 가운데(20)는 앵커 기준 상대 단면 보간.
|
||||
{
|
||||
const sections = [station(0, "both_fill", 96, 1.5), station(40, "both_fill", 96, 2.0)];
|
||||
const { field } = fieldOf(sections, () => 96);
|
||||
results.taper = {
|
||||
at0: r4(field.deformZ(0.1, HALF + 2, 96)),
|
||||
at20: r4(field.deformZ(20, HALF + 2, 96)),
|
||||
at40: r4(field.deformZ(39.9, HALF + 2, 96)),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑤ 단면 밖 마지막 구배 연장 · 반대 측 빌리기 — 단면 함수 단독.
|
||||
{
|
||||
const profile = Float64Array.from([0, 0, 2, -1, 4, -2]);
|
||||
results.profile = {
|
||||
mid: r4(deform.profileZ(profile, 1)),
|
||||
extend: r4(deform.profileZ(profile, 10)),
|
||||
noExtend: deform.profileZ(profile, 10, true),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑥ Three 지형 — 사본에만 · XY 고정 · 노면부 구멍 · 색 · 흑백 보존 표식.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
build.sections = sections;
|
||||
const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] };
|
||||
const cx = 20;
|
||||
const cy = 0;
|
||||
const cz = 100;
|
||||
const step = 0.5;
|
||||
const nx = Math.round(60 / step) + 1;
|
||||
const ny = Math.round(40 / step) + 1;
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
const colors = new Uint8Array(nx * ny * 4).fill(200);
|
||||
for (let j = 0; j < ny; j += 1) {
|
||||
for (let i = 0; i < nx; i += 1) {
|
||||
const v = j * nx + i;
|
||||
positions[v * 3] = -10 + i * step - cx;
|
||||
positions[v * 3 + 1] = 96 - cz;
|
||||
positions[v * 3 + 2] = -(-20 + j * step - cy);
|
||||
}
|
||||
}
|
||||
const index = [];
|
||||
for (let j = 0; j < ny - 1; j += 1) {
|
||||
for (let i = 0; i < nx - 1; i += 1) {
|
||||
const a = j * nx + i;
|
||||
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true));
|
||||
geometry.setIndex(index);
|
||||
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
|
||||
const before = positions.slice();
|
||||
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
|
||||
const heightIndex = new TerrainHeightIndex(terrain);
|
||||
const out = deformTerrain(split, build, bounds, heightIndex);
|
||||
let xyMoved = 0;
|
||||
let inRoad = 0;
|
||||
let probe = null;
|
||||
let keepCount = 0;
|
||||
let tinted = 0;
|
||||
out.root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const c = child.geometry.getAttribute("color");
|
||||
for (let v = 0; v < nx * ny; v += 1) {
|
||||
if (p.getX(v) !== before[v * 3] || p.getZ(v) !== before[v * 3 + 2]) xyMoved += 1;
|
||||
const mx = p.getX(v) + cx;
|
||||
const my = -p.getZ(v) + cy;
|
||||
if (Math.abs(mx - 10) < 1e-6 && Math.abs(my - (HALF + 2)) < 1e-6) probe = p.getY(v) + cz;
|
||||
if (child.userData.keepColor[v]) {
|
||||
keepCount += 1;
|
||||
if (c.getX(v) !== 200 / 255) tinted += 1;
|
||||
}
|
||||
}
|
||||
const idx = child.geometry.getIndex();
|
||||
for (let t = 0; t < idx.count; t += 3) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
sx += p.getX(idx.getX(t + k)) + cx;
|
||||
sy += -p.getZ(idx.getX(t + k)) + cy;
|
||||
}
|
||||
sx /= 3;
|
||||
sy /= 3;
|
||||
if (sx > 0.5 && sx < 39.5 && Math.abs(sy) < HALF - 0.05) inRoad += 1;
|
||||
}
|
||||
});
|
||||
results.three = {
|
||||
originalUntouched: before.every((value, i) => value === positions[i]),
|
||||
xyMoved,
|
||||
inRoad,
|
||||
probe: r4(probe),
|
||||
moved: out.stats.moved,
|
||||
raised: out.stats.raised,
|
||||
keepCount,
|
||||
tinted,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다.
|
||||
{
|
||||
const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
|
||||
const env = upperEnvelope(
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2.1, -3],
|
||||
[1.8, -3.2],
|
||||
[5, -4],
|
||||
].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
);
|
||||
const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m;
|
||||
results.envelope = {
|
||||
monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m),
|
||||
at18: r4(at(1.8)),
|
||||
at21: r4(at(2.1)),
|
||||
at5: r4(at(5)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */
|
||||
function slopeOf(points, opts = {}) {
|
||||
const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
|
||||
return {
|
||||
chainage_m: 20,
|
||||
side: "left",
|
||||
kind: "fill",
|
||||
span: { beforeM: 2, afterM: 2 },
|
||||
points: resample(
|
||||
points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
48,
|
||||
),
|
||||
endsAtStructure: false,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5).
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
const shelf = slopeOf([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[6, -4],
|
||||
]);
|
||||
const source = deform.stationProfileSource(sections, [shelf]);
|
||||
const field = deform.buildDeformField(build, source, () => 96);
|
||||
results.span = {
|
||||
in20: r4(field.deformZ(20, HALF + 2, 96)),
|
||||
in19: r4(field.deformZ(19, HALF + 2, 96)),
|
||||
taper10: r4(field.deformZ(10, HALF + 2, 96)),
|
||||
taper30: r4(field.deformZ(30, HALF + 2, 96)),
|
||||
right20: r4(field.deformZ(20, -HALF - 2, 96)),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
const box = slopeOf(
|
||||
[
|
||||
[0, 0],
|
||||
[3, -1],
|
||||
],
|
||||
{ endsAtStructure: true },
|
||||
);
|
||||
const field = deform.buildDeformField(
|
||||
build,
|
||||
deform.stationProfileSource(sections, [box]),
|
||||
() => 96,
|
||||
);
|
||||
results.box = {
|
||||
inside: r4(field.deformZ(20, HALF + 2, 96)),
|
||||
outside: r4(field.deformZ(20, HALF + 5, 96)),
|
||||
normal: r4(field.deformZ(5, HALF + 5, 96)),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
const source = deform.stationProfileSource(sections);
|
||||
const loop = [
|
||||
[8, 4],
|
||||
[12, 4],
|
||||
[12, 8],
|
||||
[8, 8],
|
||||
];
|
||||
const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]);
|
||||
const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]);
|
||||
const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
|
||||
const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 });
|
||||
const wings = [
|
||||
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] },
|
||||
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] },
|
||||
];
|
||||
const limits = noFillLimits(sections, wings);
|
||||
results.noFill = {
|
||||
blocked: r4(none.deformZ(10, HALF + 2, 96)),
|
||||
ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)),
|
||||
outsideLoop: r4(none.deformZ(20, HALF + 2, 96)),
|
||||
limits: limits.length,
|
||||
hullArea: limits.length ? limits[0].loop.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
build.sections = sections;
|
||||
const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 });
|
||||
build.structures = [
|
||||
{
|
||||
chainage_m: 20,
|
||||
kind: "revet",
|
||||
polygon: [
|
||||
[5, 95],
|
||||
[6, 95],
|
||||
[6, 100],
|
||||
[5, 100],
|
||||
],
|
||||
rings: [ring(19), ring(20), ring(21)],
|
||||
},
|
||||
];
|
||||
const step = 0.5;
|
||||
const cx = 20;
|
||||
const cz = 100;
|
||||
const positions = [];
|
||||
const nx = 121;
|
||||
const ny = 81;
|
||||
for (let j = 0; j < ny; j += 1) {
|
||||
for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step));
|
||||
}
|
||||
const index = [];
|
||||
for (let j = 0; j < ny - 1; j += 1) {
|
||||
for (let i = 0; i < nx - 1; i += 1) {
|
||||
const a = j * nx + i;
|
||||
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setIndex(index);
|
||||
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial());
|
||||
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
|
||||
const out = deformTerrain(
|
||||
split,
|
||||
build,
|
||||
{ x: [-10, 50], y: [-20, 20], z: [90, 110] },
|
||||
new TerrainHeightIndex(terrain),
|
||||
);
|
||||
let inHole = 0;
|
||||
let nearHole = 0;
|
||||
out.root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const idx = child.geometry.getIndex();
|
||||
for (let t = 0; t < idx.count; t += 3) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
sx += p.getX(idx.getX(t + k)) + cx;
|
||||
sy += -p.getZ(idx.getX(t + k));
|
||||
}
|
||||
sx /= 3;
|
||||
sy /= 3;
|
||||
if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1;
|
||||
if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1;
|
||||
}
|
||||
});
|
||||
results.hole = { holes: out.stats.holes, inHole, nearHole };
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 115 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
|
||||
|
||||
Node 도우미(helper_115_corridor_deform.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
|
||||
결과(JSON)를 단언한다. 정점 규칙 `z' = clamp(지형, 성토선, 절토선)` · 비탈 끝 밖 원지반 ·
|
||||
측점 사이 보간 · 단면 밖 마지막 구배 연장 · 사본에만 · XY 고정 · 노면부 구멍 · 색.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FILL_2M = round(100 - 2 / 1.5, 4)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_115_corridor_deform.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_fill_clamp_raises_to_fill_line(results):
|
||||
r = results["fill"]
|
||||
assert r["rows"] >= 2
|
||||
assert r["anchorL"][1:] == [3, 100]
|
||||
assert r["left2"] == FILL_2M
|
||||
assert r["right2"] == FILL_2M
|
||||
assert r["boundary"] == 100 # 노면부 끝 경계 정점 = 앵커 z
|
||||
assert r["beyond"] == 96 # 비탈 끝(6 m) 밖은 원지반
|
||||
assert r["above"] == 99.5 # 지반이 성토선 위면 그대로
|
||||
|
||||
|
||||
def test_fill_stops_at_first_catch(results):
|
||||
r = results["valley"]
|
||||
assert r["inside"] == FILL_2M
|
||||
assert r["farValley"] == 80 # 연장선(92)으로 골짜기를 메우지 않는다
|
||||
|
||||
|
||||
def test_cut_clamp_lowers_to_cut_line(results):
|
||||
r = results["cut"]
|
||||
assert r["left2"] == 102
|
||||
assert r["beyond"] == 104
|
||||
assert r["below"] == 101 # 지반이 절토선 아래면 그대로
|
||||
|
||||
|
||||
def test_station_interpolation(results):
|
||||
r = results["taper"]
|
||||
assert abs(r["at0"] - FILL_2M) < 0.02
|
||||
assert abs(r["at40"] - 99.0) < 0.02
|
||||
assert abs(r["at20"] - round(100 - 2 * 4 / 7, 4)) < 1e-3 # 상대 단면 보간
|
||||
|
||||
|
||||
def test_profile_extends_last_grade(results):
|
||||
r = results["profile"]
|
||||
assert r["mid"] == -0.5
|
||||
assert r["extend"] == -5
|
||||
assert r["noExtend"] is None
|
||||
|
||||
|
||||
def test_three_copy_only_xy_fixed_hole_and_tint(results):
|
||||
r = results["three"]
|
||||
assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로([예상형상] OFF = 원본)
|
||||
assert r["xyMoved"] == 0
|
||||
assert r["inRoad"] == 0 # 노면부 안 삼각형 없음
|
||||
assert r["probe"] == FILL_2M
|
||||
assert r["moved"] > 0 and r["raised"] == r["moved"]
|
||||
assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]
|
||||
|
||||
|
||||
def test_upper_envelope_keeps_top_of_wall_steps(results):
|
||||
r = results["envelope"]
|
||||
assert r["monotone"] is True
|
||||
assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0)
|
||||
assert r["at21"] == -3
|
||||
assert r["at5"] == -4
|
||||
|
||||
|
||||
def test_structure_span_fixed_and_tapers_to_neighbours(results):
|
||||
r = results["span"]
|
||||
assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정
|
||||
assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼
|
||||
assert r["taper30"] == r["taper10"]
|
||||
assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로
|
||||
|
||||
|
||||
def test_box_outside_body_stays_original_ground(results):
|
||||
r = results["box"]
|
||||
assert r["inside"] == round(100 - 2 / 3, 4)
|
||||
assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함)
|
||||
assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형
|
||||
|
||||
|
||||
def test_no_fill_ring_inside_wings(results):
|
||||
r = results["noFill"]
|
||||
assert r["blocked"] == 96
|
||||
assert r["ceiling"] == 97
|
||||
assert r["outsideLoop"] == FILL_2M
|
||||
assert r["limits"] == 1 and r["hullArea"] == 4
|
||||
|
||||
|
||||
def test_structure_footprint_hole(results):
|
||||
r = results["hole"]
|
||||
assert r["holes"] == 1
|
||||
assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음
|
||||
assert r["nearHole"] > 0 # 바로 바깥은 남는다
|
||||
Reference in New Issue
Block a user