Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Loft.ts
|
||||
* 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 사용자 안) — 비탈을 따로 그려 넣는다.
|
||||
*
|
||||
* ① 측점 단면 = B06 설계선 비탈 꼭짓점(`_Loft_Profile.ts`) · 비탈 끝 = 평면도 외곽선과 같은 규칙.
|
||||
* ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 직선 보간(바깥거리 ·
|
||||
* 높이 둘 다) → 비탈 끝 선 = 평면도 외곽선(비탈 끝 오프셋 노선 따라 보간). 성토 ↔ 절토가
|
||||
* 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양, 끝 오프셋만 보간).
|
||||
* ① · ② 처럼 행마다 지형과 다시 맞추지 않는다.
|
||||
* ③ 드레이프 = 비탈 끝 높이를 그 자리 지형에 맞추고 보정량을 노면부 끝 0 → 비탈 끝 1 로 퍼뜨림.
|
||||
* ④ 곡선 안쪽 겹침 = 앞 행 단면에 닿으면 그 자리에서 자름(제안 ② 와 같은 청소).
|
||||
* 지형 이음 · 구조물 구멍 · 구역 색 · 망 = 제안 ② 기계(`_Tin.ts` tinTerrain) 그대로 — 설계 점 원천만 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
import type * 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 { roadRows } from "./B05_Profile_UI_Corridor_Deform";
|
||||
import { noFillLimits } from "./B05_Profile_UI_Corridor_Deform_Structure";
|
||||
import { tinTerrain, type TinDesigner, type TinStats } from "./B05_Profile_UI_Corridor_Tin";
|
||||
import {
|
||||
insideLoop,
|
||||
SECTION_STEP_M,
|
||||
structureTopLoops,
|
||||
type TinDesign,
|
||||
} from "./B05_Profile_UI_Corridor_Tin_Design";
|
||||
import {
|
||||
EDGE_ANCHOR,
|
||||
EDGE_DESIGN,
|
||||
EDGE_HOLE,
|
||||
Pslg,
|
||||
SegGrid,
|
||||
TAG_ANCHOR,
|
||||
TAG_CUT,
|
||||
TAG_FILL,
|
||||
TAG_HOLE,
|
||||
} from "./B05_Profile_UI_Corridor_Tin_Pslg";
|
||||
import { endOf, pairProfiles, stationLofts } from "./B05_Profile_UI_Corridor_Loft_Profile";
|
||||
|
||||
/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */
|
||||
const MIN_CATCH_M = 0.02;
|
||||
/** 종방향 선 — 사이 행 비탈 끝이 이만큼 바깥에 있어야 잇는다(m). */
|
||||
const LINE_GAP_M = 0.05;
|
||||
/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */
|
||||
const HOLE_REACH_M = 80;
|
||||
|
||||
/** 한 행 · 한 측 단면 보기 — key 가 같은 행끼리 점 수가 같다(종방향 선). */
|
||||
interface View {
|
||||
key: number;
|
||||
kind: 0 | 1;
|
||||
/** [s0, dz0, …] — 노면부 끝 기준. */
|
||||
pts: Float64Array;
|
||||
}
|
||||
|
||||
/** 드레이프한 행 단면 — 바깥거리 · 절대 높이(모델). */
|
||||
interface Slope {
|
||||
s: Float64Array;
|
||||
z: Float64Array;
|
||||
}
|
||||
|
||||
/** 제안 ③ 설계 점 · 브레이크라인 · 구멍 — 제안 ② 와 같은 꼴(TinDesign). */
|
||||
export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
const rows = roadRows(build);
|
||||
const n = rows.length;
|
||||
const stations = stationLofts(build.sections ?? []);
|
||||
if (n < 2 || !stations.length) return null;
|
||||
const last = stations.length - 1;
|
||||
const limits = noFillLimits(build.sections ?? [], 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 sx = (mx: number): number => mx - ox;
|
||||
const sz = (my: number): number => -(my - oy);
|
||||
const pslg = new Pslg();
|
||||
const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 };
|
||||
|
||||
// 행 틀 — 좌향 단위 벡터 · 반폭 · 중심.
|
||||
const ux = new Float64Array(n);
|
||||
const uy = new Float64Array(n);
|
||||
const hw = new Float64Array(n);
|
||||
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;
|
||||
ux[r] = dx / width;
|
||||
uy[r] = dy / width;
|
||||
hw[r] = width / 2;
|
||||
});
|
||||
const anchorOf = (r: number, si: number) => (si === 0 ? rows[r].left : rows[r].right);
|
||||
const pointAt = (r: number, si: number, s: number): [number, number] => {
|
||||
const anchor = anchorOf(r, si);
|
||||
const sign = si === 0 ? 1 : -1;
|
||||
return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s];
|
||||
};
|
||||
|
||||
/** 누가거리를 감싼 측점 마디 [i, t] — 범위 밖은 끝 마디. */
|
||||
const bracket = (c: number): [number, number] => {
|
||||
if (!last || c <= stations[0].chainage_m) return [0, 0];
|
||||
if (c >= stations[last].chainage_m) return [last - 1, 1];
|
||||
let lo = 0;
|
||||
let hi = last;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (stations[mid].chainage_m <= c) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const span = stations[lo + 1].chainage_m - stations[lo].chainage_m;
|
||||
return [lo, span > 1e-9 ? (c - stations[lo].chainage_m) / span : 0];
|
||||
};
|
||||
const pairs = new Map<number, [Float64Array, Float64Array]>();
|
||||
const view = (i: number, t: number, si: number): View | null => {
|
||||
const a = stations[i].sides[si];
|
||||
const b = stations[Math.min(i + 1, last)].sides[si];
|
||||
if (!a && !b) return null;
|
||||
if (a && b && a.kind !== b.kind) {
|
||||
// 성토 ↔ 절토 — 평면도처럼 가운데서 끊는다. 모양은 제 측점 것 · 끝 오프셋만 보간.
|
||||
const half = t < 0.5 ? 0 : 1;
|
||||
const own = half ? b : a;
|
||||
const scale = (endOf(a) + (endOf(b) - endOf(a)) * t) / endOf(own);
|
||||
return { key: i * 2 + half, kind: own.kind, pts: own.pts.map((v) => v * scale) };
|
||||
}
|
||||
const slot = i * 2 + si;
|
||||
let pair = pairs.get(slot);
|
||||
if (!pair) {
|
||||
pair = pairProfiles(a, b);
|
||||
pairs.set(slot, pair);
|
||||
}
|
||||
const [pa, pb] = pair;
|
||||
const kind = (a ?? b)?.kind ?? 0;
|
||||
return { key: i * 2, kind, pts: pa.map((v, k) => v + (pb[k] - v) * t) };
|
||||
};
|
||||
/** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 s / 끝 비율. */
|
||||
const drape = (r: number, si: number, v: View): Slope | null => {
|
||||
const m = v.pts.length / 2;
|
||||
const end = v.pts[(m - 1) * 2];
|
||||
if (end < MIN_CATCH_M) return null;
|
||||
const anchor = anchorOf(r, si);
|
||||
const [ex, ey] = pointAt(r, si, end);
|
||||
const ground = groundAt(ex, ey);
|
||||
const delta = ground === null ? 0 : ground - (anchor[2] + v.pts[(m - 1) * 2 + 1]);
|
||||
const s = new Float64Array(m);
|
||||
const z = new Float64Array(m);
|
||||
for (let k = 0; k < m; k += 1) {
|
||||
s[k] = v.pts[k * 2];
|
||||
z[k] = anchor[2] + v.pts[k * 2 + 1] + (delta * s[k]) / end;
|
||||
}
|
||||
return { s, z };
|
||||
};
|
||||
const zAt = (slope: Slope, s: number): number => {
|
||||
for (let k = 1; k < slope.s.length; k += 1) {
|
||||
if (s <= slope.s[k] || k === slope.s.length - 1) {
|
||||
const span = slope.s[k] - slope.s[k - 1];
|
||||
const t = span <= 1e-12 ? 1 : Math.min(1, (s - slope.s[k - 1]) / span);
|
||||
return slope.z[k - 1] + (slope.z[k] - slope.z[k - 1]) * t;
|
||||
}
|
||||
}
|
||||
return slope.z[0];
|
||||
};
|
||||
/** 성토 금지 고리 안은 원지반(또는 천장) 위로 못 올린다(제안 ② designZ 와 같은 규칙). */
|
||||
const limited = (x: number, y: number, z: number): number => {
|
||||
const ground = groundAt(x, y);
|
||||
if (ground === null || z <= ground) return z;
|
||||
for (const limit of limits) {
|
||||
if (z > ground && insideLoop(limit.loop, x, y))
|
||||
z = Math.min(z, Math.max(ground, limit.ceiling ?? ground));
|
||||
}
|
||||
return z;
|
||||
};
|
||||
const tagOf = (kind: number): number => (kind === 0 ? TAG_FILL : TAG_CUT);
|
||||
|
||||
// 노면 끝 선 — 리본 바깥 끝 그대로(제안 ② 와 같음).
|
||||
const anchors = [0, 1].map((si) =>
|
||||
rows.map((_, r) => {
|
||||
const [x, y, z] = anchorOf(r, si);
|
||||
return pslg.addPoint(sx(x), sz(y), z - oz, TAG_ANCHOR);
|
||||
}),
|
||||
);
|
||||
anchors.forEach((ids) => pslg.addPath(ids, EDGE_ANCHOR));
|
||||
pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR);
|
||||
pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR);
|
||||
|
||||
// 비탈 끝 — 행마다(평면도 외곽선과 같은 보간) · 곡선 안쪽 겹침은 앞 행 단면에 닿는 자리에서 자름.
|
||||
const keyOf = [0, 1].map(() => new Int32Array(n).fill(-1));
|
||||
const reachOf = [0, 1].map(() => new Float64Array(n));
|
||||
const daylight = [0, 1].map(() => new Int32Array(n));
|
||||
[0, 1].forEach((si) => {
|
||||
const fan = new SegGrid(2);
|
||||
for (let r = 0; r < n; r += 1) {
|
||||
daylight[si][r] = anchors[si][r];
|
||||
const [i, t] = bracket(rows[r].chainage_m);
|
||||
const v = view(i, t, si);
|
||||
const slope = v && drape(r, si, v);
|
||||
if (!v || !slope) continue;
|
||||
const end = slope.s[slope.s.length - 1];
|
||||
const ax = pslg.x[anchors[si][r]];
|
||||
const az = pslg.z[anchors[si][r]];
|
||||
const [dx, dy] = pointAt(r, si, end);
|
||||
const hit = fan.firstHit(ax, az, sx(dx), sz(dy));
|
||||
let reach = end;
|
||||
if (hit < 1) {
|
||||
reach *= hit;
|
||||
stats.bowtie += 1;
|
||||
if (reach < MIN_CATCH_M) continue;
|
||||
}
|
||||
const [px, py] = pointAt(r, si, reach);
|
||||
const z = limited(px, py, zAt(slope, reach));
|
||||
daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, tagOf(v.kind));
|
||||
keyOf[si][r] = v.key;
|
||||
reachOf[si][r] = reach;
|
||||
fan.add(r, ax, az, sx(px), sz(py));
|
||||
}
|
||||
});
|
||||
|
||||
// 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리.
|
||||
const left = Array.from(daylight[0]);
|
||||
const right = Array.from(daylight[1]);
|
||||
pslg.addPath(left, EDGE_DESIGN);
|
||||
pslg.addPath(right, EDGE_DESIGN);
|
||||
pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN);
|
||||
pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN);
|
||||
pslg.addPath([anchors[0][n - 1], left[n - 1]], EDGE_DESIGN);
|
||||
pslg.addPath([anchors[1][n - 1], right[n - 1]], EDGE_DESIGN);
|
||||
const loopIds = [
|
||||
anchors[0][0],
|
||||
...left,
|
||||
anchors[0][n - 1],
|
||||
anchors[1][n - 1],
|
||||
...right.reverse(),
|
||||
anchors[1][0],
|
||||
];
|
||||
const outer = loopIds
|
||||
.filter((id, i) => i === 0 || id !== loopIds[i - 1])
|
||||
.map((id): [number, number] => [pslg.x[id], pslg.z[id]]);
|
||||
|
||||
// 단면 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤.
|
||||
const stationAt = (c: number): number =>
|
||||
stations.findIndex((station) => Math.abs(station.chainage_m - c) < 1e-3);
|
||||
const mark = new Uint8Array(n);
|
||||
let previous = -Infinity;
|
||||
for (let r = 0; r < n; r += 1) {
|
||||
const c = rows[r].chainage_m;
|
||||
const changed = r > 0 && [0, 1].some((si) => keyOf[si][r] !== keyOf[si][r - 1]);
|
||||
if (changed) mark[r - 1] = 1;
|
||||
if (
|
||||
r === 0 ||
|
||||
r === n - 1 ||
|
||||
changed ||
|
||||
stationAt(c) >= 0 ||
|
||||
c - previous >= SECTION_STEP_M - 1e-6
|
||||
) {
|
||||
mark[r] = 1;
|
||||
previous = c;
|
||||
}
|
||||
}
|
||||
|
||||
// 횡방향 선(노면 끝 → 꼭짓점 → 비탈 끝) · 같은 보기의 같은 번째 꼭짓점을 잇는 종방향 선.
|
||||
[0, 1].forEach((si) => {
|
||||
let before: { r: number; ids: Map<number, { s: Float64Array; id: Int32Array }> } | null = null;
|
||||
for (let r = 0; r < n; r += 1) {
|
||||
if (!mark[r]) continue;
|
||||
stats.sections += si === 0 ? 1 : 0;
|
||||
const reach = reachOf[si][r];
|
||||
if (keyOf[si][r] < 0) {
|
||||
before = null;
|
||||
continue;
|
||||
}
|
||||
const j = stationAt(rows[r].chainage_m);
|
||||
const spots: Array<[number, number]> =
|
||||
j >= 0
|
||||
? [
|
||||
...(j > 0 ? [[j - 1, 1] as [number, number]] : []),
|
||||
...(j < last || !last ? [[j, 0] as [number, number]] : []),
|
||||
]
|
||||
: [bracket(rows[r].chainage_m)];
|
||||
const ids = new Map<number, { s: Float64Array; id: Int32Array }>();
|
||||
const path: Array<[number, number]> = [];
|
||||
for (const [i, t] of spots) {
|
||||
const v = view(i, t, si);
|
||||
const slope = v && drape(r, si, v);
|
||||
if (!v || !slope) continue;
|
||||
const m = slope.s.length;
|
||||
const id = new Int32Array(m).fill(-1);
|
||||
for (let k = 1; k < m - 1; k += 1) {
|
||||
if (slope.s[k] >= reach - LINE_GAP_M) break;
|
||||
const [x, y] = pointAt(r, si, slope.s[k]);
|
||||
id[k] = pslg.addPoint(sx(x), sz(y), limited(x, y, slope.z[k]) - oz, tagOf(v.kind));
|
||||
path.push([slope.s[k], id[k]]);
|
||||
}
|
||||
ids.set(v.key, { s: slope.s, id });
|
||||
}
|
||||
path.sort((a, b) => a[0] - b[0]);
|
||||
const line = [anchors[si][r], ...path.map(([, id]) => id), daylight[si][r]];
|
||||
pslg.addPath(
|
||||
line.filter((id, k) => k === 0 || id !== line[k - 1]),
|
||||
EDGE_DESIGN,
|
||||
);
|
||||
if (before) {
|
||||
for (const [key, now] of ids) {
|
||||
const then = before.ids.get(key);
|
||||
if (!then || then.id.length !== now.id.length) continue;
|
||||
for (let k = 1; k < now.id.length - 1; k += 1) {
|
||||
if (then.id[k] < 0 || now.id[k] < 0) continue;
|
||||
// 사이 행 비탈 끝이 그 꼭짓점보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감).
|
||||
let inside = true;
|
||||
for (let q = before.r + 1; q < r && inside; q += 1) {
|
||||
const f = (q - before.r) / (r - before.r);
|
||||
const s = then.s[k] + (now.s[k] - then.s[k]) * f;
|
||||
if (keyOf[si][q] < 0 || reachOf[si][q] < s + LINE_GAP_M) inside = false;
|
||||
}
|
||||
if (!inside) continue;
|
||||
pslg.addEdge(then.id[k], now.id[k], EDGE_DESIGN);
|
||||
stats.bendLines += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
before = { r, ids };
|
||||
}
|
||||
});
|
||||
|
||||
// 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(제안 ② 와 같은 드레이프).
|
||||
const surfaceAt = (x: number, y: number): number | null => {
|
||||
let best: { r: number; t: number; lateral: number } | null = null;
|
||||
const along = (r: number): number =>
|
||||
(x - (rows[r].left[0] + rows[r].right[0]) / 2) * uy[r] -
|
||||
(y - (rows[r].left[1] + rows[r].right[1]) / 2) * ux[r];
|
||||
const lateral = (r: number): number =>
|
||||
(x - (rows[r].left[0] + rows[r].right[0]) / 2) * ux[r] +
|
||||
(y - (rows[r].left[1] + rows[r].right[1]) / 2) * uy[r];
|
||||
// ponytail: 행 전수 훑기 — 구멍 둘레 점만 부르므로 충분 · 느려지면 `_Deform.ts` locate 격자로.
|
||||
let a0 = along(0);
|
||||
for (let r = 0; r + 1 < n; r += 1) {
|
||||
const a1 = along(r + 1);
|
||||
if (a0 >= 0 && a1 < 0) {
|
||||
const t = a0 / (a0 - a1);
|
||||
const o = lateral(r) * (1 - t) + lateral(r + 1) * t;
|
||||
if (Math.abs(o) < HOLE_REACH_M && (!best || Math.abs(o) < Math.abs(best.lateral))) {
|
||||
best = { r, t, lateral: o };
|
||||
}
|
||||
}
|
||||
a0 = a1;
|
||||
}
|
||||
if (!best) return null;
|
||||
const si = best.lateral >= 0 ? 0 : 1;
|
||||
const height = (r: number): number | null => {
|
||||
const s = Math.abs(best!.lateral) - hw[r];
|
||||
if (s <= 0) return anchorOf(r, si)[2];
|
||||
if (keyOf[si][r] < 0 || s >= reachOf[si][r]) return null;
|
||||
const [i, t] = bracket(rows[r].chainage_m);
|
||||
const v = view(i, t, si);
|
||||
const slope = v && drape(r, si, v);
|
||||
return slope ? zAt(slope, s) : null;
|
||||
};
|
||||
const h0 = height(best.r);
|
||||
const h1 = height(best.r + 1);
|
||||
if (h0 === null || h1 === null) return null;
|
||||
return h0 + (h1 - h0) * best.t;
|
||||
};
|
||||
const holes: Array<Array<[number, number]>> = [];
|
||||
for (const loop of structureTopLoops(build.structures ?? [])) {
|
||||
const ids = loop.map(([x, y, top]) => {
|
||||
const surface = surfaceAt(x, y) ?? groundAt(x, y) ?? top;
|
||||
return pslg.addPoint(sx(x), sz(y), Math.min(surface, top) - oz, TAG_HOLE);
|
||||
});
|
||||
pslg.addPath(ids, EDGE_HOLE, true);
|
||||
holes.push(loop.map(([x, y]): [number, number] => [sx(x), sz(y)]));
|
||||
}
|
||||
stats.holes = holes.length;
|
||||
stats.splits = pslg.splitCrossings();
|
||||
|
||||
const plan = (si: number): Array<[number, number]> =>
|
||||
anchors[si].map((id): [number, number] => [pslg.x[id], pslg.z[id]]);
|
||||
const design: TinDesign = { pslg, outer, holes, road: { left: plan(0), right: plan(1) }, stats };
|
||||
return design;
|
||||
};
|
||||
|
||||
/** 제안 ③ 지형 — 제안 ② 의 지형 이음 · 구멍 · 구역 색에 측점 횡단 기반 사면을 넣는다. */
|
||||
export function loftTerrain(
|
||||
split: TerrainBandSplit,
|
||||
build: CorridorBuildResult,
|
||||
bounds: ModelBounds,
|
||||
heightIndex: TerrainHeightIndex,
|
||||
): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null {
|
||||
return tinTerrain(split, build, bounds, heightIndex, loftDesign);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Loft_Profile.ts
|
||||
* 3D 제안 ③(계획서 115-8)의 **측점 비탈 단면** · 이웃 측점 단면 짝 맞추기 — 순수 기하.
|
||||
*
|
||||
* · 측점 단면 = B06 설계선(design_line)의 비탈 시작(노면부 바깥 끝) → 비탈 끝 원 꼭짓점.
|
||||
* 비탈 끝 · 종류는 `_Station.ts` classifyStation(조각 끝 = catchOffset) 그대로 —
|
||||
* 평면도 외곽선 `B07_DesignDetail_Engine_Cad_PlanKinds.py` slope_end 와 짝(설계선이
|
||||
* 원지반에 처음 닿는 오프셋 · 못 닿으면 설계선 끝).
|
||||
* · 짝 맞추기 = 꺾임(소단 · 암/토사)끼리 안쪽부터 차례로 짝 · 남는 꺾임은 마지막 마디에서
|
||||
* 길이 비율 → 두 단면이 같은 점 수.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { classifyStation, pieceKey, type OffsetPoint } from "./B05_Profile_UI_Corridor_Station";
|
||||
|
||||
/** 꺾임으로 남길 단면 꺾임각(도) — 새 방식(`_Deform.ts` BEND_DEG)과 같은 값. */
|
||||
const BEND_DEG = 8;
|
||||
|
||||
/** 측 하나의 비탈 단면 — 바깥거리 s(오름) · 비탈 시작 기준 높이 dz. kind 0 성토 · 1 절토. */
|
||||
export interface LoftProfile {
|
||||
kind: 0 | 1;
|
||||
/** [s0, dz0, s1, dz1, …] — 끝 점 = 비탈 끝. */
|
||||
pts: Float64Array;
|
||||
}
|
||||
|
||||
export interface StationLoft {
|
||||
chainage_m: number;
|
||||
/** [좌, 우] — 비탈이 없으면 null. */
|
||||
sides: [LoftProfile | null, LoftProfile | null];
|
||||
}
|
||||
|
||||
/** 끝 점 바깥거리(비탈 끝 오프셋). */
|
||||
export function endOf(profile: LoftProfile | null): number {
|
||||
return profile ? profile.pts[profile.pts.length - 2] : 0;
|
||||
}
|
||||
|
||||
/** 두 꼭짓점 사이 꺾이지 않은 점은 뺀다(끝 점은 남김). */
|
||||
function bendsOnly(points: Array<[number, number]>): Array<[number, number]> {
|
||||
const out: Array<[number, number]> = [points[0]];
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const [s0, z0] = out[out.length - 1];
|
||||
const [s1, z1] = points[i];
|
||||
const [s2, z2] = points[i + 1];
|
||||
if (Math.hypot(s1 - s0, z1 - z0) < 1e-6) continue;
|
||||
const a0 = Math.atan2(z1 - z0, s1 - s0);
|
||||
const a1 = Math.atan2(z2 - z1, s2 - s1);
|
||||
if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) out.push(points[i]);
|
||||
}
|
||||
out.push(points[points.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 설계선에서 [시작, 끝] 사이 원 꼭짓점 — 시작에서 바깥으로(바깥거리 · 높이 차). */
|
||||
function slopeVertices(
|
||||
line: ReadonlyArray<OffsetPoint>,
|
||||
start: number,
|
||||
end: number,
|
||||
): Array<[number, number]> {
|
||||
const sign = end >= start ? 1 : -1;
|
||||
const zAt = (offset: number): number => {
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const a = line[i - 1];
|
||||
const b = line[i];
|
||||
if (offset <= b.offset_m + 1e-9) {
|
||||
const span = b.offset_m - a.offset_m;
|
||||
const t = span <= 1e-12 ? 0 : (offset - a.offset_m) / span;
|
||||
return a.elevation_m + (b.elevation_m - a.elevation_m) * Math.max(0, Math.min(1, t));
|
||||
}
|
||||
}
|
||||
return line[line.length - 1].elevation_m;
|
||||
};
|
||||
const z0 = zAt(start);
|
||||
const reach = Math.abs(end - start);
|
||||
const points: Array<[number, number]> = [[0, 0]];
|
||||
const inner = line
|
||||
.map((p): [number, number] => [(p.offset_m - start) * sign, p.elevation_m - z0])
|
||||
.filter(([s]) => s > 1e-6 && s < reach - 1e-6)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
points.push(...inner, [reach, zAt(end) - z0]);
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 측점마다 좌우 비탈 단면 — 분류가 안 되는 측점은 뺀다. 누가거리 오름차순. */
|
||||
export function stationLofts(sections: ReadonlyArray<CrossSection>): StationLoft[] {
|
||||
const out: StationLoft[] = [];
|
||||
for (const section of sections) {
|
||||
const pieces = classifyStation(section);
|
||||
const raw = section.design?.design_line;
|
||||
if (!pieces || !raw) continue;
|
||||
const line = [...raw].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const sides = (["left", "right"] as const).map((side): LoftProfile | null => {
|
||||
const fill = pieces.pieces.get(pieceKey("fill", side));
|
||||
const piece = fill ?? pieces.pieces.get(pieceKey("cut", side));
|
||||
if (!piece || piece.length < 2) return null;
|
||||
const lo = piece[0].offset_m;
|
||||
const hi = piece[piece.length - 1].offset_m;
|
||||
const [start, end] = side === "left" ? [lo, hi] : [hi, lo];
|
||||
const points = bendsOnly(slopeVertices(line, start, end));
|
||||
if (points[points.length - 1][0] < 1e-6) return null;
|
||||
return { kind: fill ? 0 : 1, pts: Float64Array.from(points.flat()) };
|
||||
}) as StationLoft["sides"];
|
||||
out.push({ chainage_m: section.chainage_m, sides });
|
||||
}
|
||||
return out.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
/** 꼭짓점 [from, to] 마디의 정규 호길이(0~1). */
|
||||
function arcParams(pts: Float64Array, from: number, to: number): number[] {
|
||||
const lengths = [0];
|
||||
for (let i = from + 1; i <= to; i += 1) {
|
||||
const ds = pts[i * 2] - pts[i * 2 - 2];
|
||||
const dz = pts[i * 2 + 1] - pts[i * 2 - 1];
|
||||
lengths.push(lengths[lengths.length - 1] + Math.hypot(ds, dz));
|
||||
}
|
||||
const total = lengths[lengths.length - 1];
|
||||
return lengths.map((value, i) => (total > 1e-9 ? value / total : i / (lengths.length - 1)));
|
||||
}
|
||||
|
||||
/** from 에서 시작하는 마디에서 정규 호길이 u 자리(params = arcParams). */
|
||||
function pointAtParam(
|
||||
pts: Float64Array,
|
||||
from: number,
|
||||
params: number[],
|
||||
u: number,
|
||||
): [number, number] {
|
||||
for (let k = 1; k < params.length; k += 1) {
|
||||
if (u <= params[k] + 1e-12 || k === params.length - 1) {
|
||||
const span = params[k] - params[k - 1];
|
||||
const t = span <= 1e-12 ? 0 : Math.max(0, Math.min(1, (u - params[k - 1]) / span));
|
||||
const i = from + k - 1;
|
||||
return [
|
||||
pts[i * 2] + (pts[i * 2 + 2] - pts[i * 2]) * t,
|
||||
pts[i * 2 + 1] + (pts[i * 2 + 3] - pts[i * 2 + 1]) * t,
|
||||
];
|
||||
}
|
||||
}
|
||||
return [pts[from * 2], pts[from * 2 + 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 단면을 같은 점 수로 — 꺾임끼리 안쪽부터 짝(적은 쪽 수만큼) · 마디마다 두 쪽 꼭짓점의
|
||||
* 정규 호길이를 합쳐 다시 짚는다. null(비탈 없음)은 시작 한 점으로 오그라든 단면.
|
||||
*/
|
||||
export function pairProfiles(
|
||||
a: LoftProfile | null,
|
||||
b: LoftProfile | null,
|
||||
): [Float64Array, Float64Array] {
|
||||
const pa = a?.pts ?? new Float64Array(4);
|
||||
const pb = b?.pts ?? new Float64Array(4);
|
||||
const na = pa.length / 2;
|
||||
const nb = pb.length / 2;
|
||||
const k = Math.min(na, nb) - 2;
|
||||
const keysA = [...Array.from({ length: k + 1 }, (_, i) => i), na - 1];
|
||||
const keysB = [...Array.from({ length: k + 1 }, (_, i) => i), nb - 1];
|
||||
const outA: number[] = [pa[0], pa[1]];
|
||||
const outB: number[] = [pb[0], pb[1]];
|
||||
for (let m = 0; m + 1 < keysA.length; m += 1) {
|
||||
const [a0, a1] = [keysA[m], keysA[m + 1]];
|
||||
const [b0, b1] = [keysB[m], keysB[m + 1]];
|
||||
const ua = arcParams(pa, a0, a1);
|
||||
const ub = arcParams(pb, b0, b1);
|
||||
const params = [...new Set([...ua, ...ub].map((u) => Math.round(u * 1e9) / 1e9))]
|
||||
.filter((u) => u > 0)
|
||||
.sort((x, y) => x - y);
|
||||
for (const u of params) {
|
||||
outA.push(...pointAtParam(pa, a0, ua, u));
|
||||
outB.push(...pointAtParam(pb, b0, ub, u));
|
||||
}
|
||||
}
|
||||
return [Float64Array.from(outA), Float64Array.from(outB)];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
|
||||
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
|
||||
import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain";
|
||||
import { tinTerrain } from "./B05_Profile_UI_Corridor_Tin";
|
||||
import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft";
|
||||
import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const MODE_SLOTS = [
|
||||
["legacy-a", "옛 A"],
|
||||
["new1", "새 ①"],
|
||||
["proposal2", "제안 ②"],
|
||||
["proposal3", "제안 ③"],
|
||||
] as const;
|
||||
|
||||
export type CorridorModeId = (typeof MODE_SLOTS)[number][0];
|
||||
@@ -164,6 +166,13 @@ export const CORRIDOR_MODES: CorridorMode[] = [
|
||||
terrain: ({ split, build, bounds, heightIndex }) =>
|
||||
heightIndex ? tinTerrain(split, build, bounds, heightIndex) : null,
|
||||
},
|
||||
// 제안 ③(115-8) — 노면부만 그리고 비탈은 측점 횡단 보간(평면도 외곽선 규칙) · 지형 이음은 ② 기계.
|
||||
{
|
||||
id: "proposal3",
|
||||
corridor: (build) => roadOnly(build),
|
||||
terrain: ({ split, build, bounds, heightIndex }) =>
|
||||
heightIndex ? loftTerrain(split, build, bounds, heightIndex) : null,
|
||||
},
|
||||
];
|
||||
|
||||
export function modeOf(id: CorridorModeId): CorridorMode | null {
|
||||
|
||||
@@ -166,32 +166,22 @@ function harvestPart(
|
||||
return { kept, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 변형 장을 못
|
||||
* 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다.
|
||||
*/
|
||||
export function tinTerrain(
|
||||
split: TerrainBandSplit,
|
||||
/** 설계 점 · 브레이크라인 · 구멍을 내는 자리 — `groundAt` = 원지반 높이(모델 좌표). 못 세우면 null. */
|
||||
export type TinDesigner = (
|
||||
build: CorridorBuildResult,
|
||||
groundAt: (x: number, y: number) => number | null,
|
||||
bounds: ModelBounds,
|
||||
heightIndex: TerrainHeightIndex,
|
||||
): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null {
|
||||
const started = performance.now();
|
||||
const sections = build.sections;
|
||||
if (!sections?.length) return null;
|
||||
) => TinDesign | null;
|
||||
|
||||
/** 제안 ② 설계 — 새 방식 변형 장(행마다 비탈 끝)에서. */
|
||||
const deformDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
const sections = build.sections ?? [];
|
||||
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;
|
||||
};
|
||||
const source = stationProfileSource(sections, structureSlopes(sections));
|
||||
const limits = noFillLimits(sections, structures);
|
||||
const field = buildDeformField(build, source, groundAt, limits);
|
||||
if (!field) return null;
|
||||
const design = buildTinDesign(
|
||||
return buildTinDesign(
|
||||
field,
|
||||
source,
|
||||
groundAt,
|
||||
@@ -200,6 +190,31 @@ export function tinTerrain(
|
||||
sections.map((section) => section.chainage_m),
|
||||
bounds,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못
|
||||
* 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다.
|
||||
* `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③).
|
||||
*/
|
||||
export function tinTerrain(
|
||||
split: TerrainBandSplit,
|
||||
build: CorridorBuildResult,
|
||||
bounds: ModelBounds,
|
||||
heightIndex: TerrainHeightIndex,
|
||||
designer: TinDesigner = deformDesign,
|
||||
): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null {
|
||||
const started = performance.now();
|
||||
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 design = designer(build, groundAt, bounds);
|
||||
if (!design) return null;
|
||||
|
||||
const outer = new LoopIndex([design.outer]);
|
||||
const holes = new LoopIndex(design.holes);
|
||||
|
||||
@@ -125,7 +125,7 @@ export function structureTopLoops(
|
||||
}
|
||||
|
||||
/** 점이 고리 안인가(광선 투사 · 모델 좌표). */
|
||||
function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
||||
export 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];
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/* PLAN 115-8 단위검증 헬퍼 — B05 3D 제안 ③ 측점 횡단 기반 사면 합성 지형 검사.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
* (트랜스파일 틀 · 합성 측점 · 격자 지형 · 틈 셈은 helper_115_6_corridor_tin.cjs 와 같다.)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const NM = path.join(ROOT, "config", "node_modules");
|
||||
const ts = require(path.join(NM, "typescript"));
|
||||
const THREE = require(path.join(NM, "three"));
|
||||
const LIBS = {
|
||||
three: THREE,
|
||||
// 기본 내보내기(ESM default) 꼴로 감싼다.
|
||||
// delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함).
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
},
|
||||
};
|
||||
|
||||
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 (LIBS[request]) return LIBS[request];
|
||||
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 { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft"));
|
||||
const { pairProfiles } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Profile"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
|
||||
/** 측점 하나 — 중심 (cx, cy) · 좌향 (lx, ly) · 지반 평탄 groundZ · 양측 같은 비탈 1:ratio. */
|
||||
function station(chainage, cx, cy, lx, ly, groundZ, ratio) {
|
||||
const w = Math.abs(groundZ - ROAD_Z) * ratio;
|
||||
const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut";
|
||||
const pts = [
|
||||
[-40, groundZ],
|
||||
[-(HALF + w), groundZ],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[HALF + w, groundZ],
|
||||
[40, groundZ],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: cx,
|
||||
center_y: cy,
|
||||
center_z: groundZ,
|
||||
frame: { left_xy: [lx, ly] },
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 격자 지형(모델 좌표 x0..x1 · y0..y1 · 간격 step) — 씬 좌표로 구운 메시. */
|
||||
function gridTerrain(bounds, x0, x1, y0, y1, step, heightAt) {
|
||||
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 nx = Math.round((x1 - x0) / step) + 1;
|
||||
const ny = Math.round((y1 - y0) / 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;
|
||||
const x = x0 + i * step;
|
||||
const y = y0 + j * step;
|
||||
positions[v * 3] = x - ox;
|
||||
positions[v * 3 + 1] = heightAt(x, y) - oz;
|
||||
positions[v * 3 + 2] = -(y - oy);
|
||||
}
|
||||
}
|
||||
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;
|
||||
// 대각을 번갈아 — 들로네가 마음대로 고르면 원래 삼각형과 달라지는 자리를 일부러 만든다.
|
||||
if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1);
|
||||
}
|
||||
}
|
||||
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 }));
|
||||
return { terrain, positions, index, ox, oy, oz };
|
||||
}
|
||||
|
||||
const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
|
||||
const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`;
|
||||
/** PSLG 점(배정밀도) → 망 정점(단정밀도)과 같은 키. */
|
||||
const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z));
|
||||
|
||||
/** 결과 망 전체(남은 원래 + TIN) 삼각형을 [[x,y,z]×3] 로. */
|
||||
function trianglesOf(root) {
|
||||
const out = [];
|
||||
root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const idx = child.geometry.getIndex();
|
||||
const count = idx ? idx.count : p.count;
|
||||
for (let t = 0; t < count; t += 3) {
|
||||
const tri = [];
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
const v = idx ? idx.getX(t + k) : t + k;
|
||||
tri.push([p.getX(v), p.getY(v), p.getZ(v)]);
|
||||
}
|
||||
out.push({ tri, tin: child.name === "terrain-tin-mesh" });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 지형 바깥 테두리(모델 사각형 x0..x1 · y0..y1) 위 변인가 — 틈 셈에서 뺀다. */
|
||||
function frameOf(bounds, x0, x1, y0, y1) {
|
||||
const ox = (bounds.x[0] + bounds.x[1]) / 2;
|
||||
const oy = (bounds.y[0] + bounds.y[1]) / 2;
|
||||
const on = (p) => {
|
||||
const x = p[0] + ox;
|
||||
const y = -p[2] + oy;
|
||||
return (
|
||||
Math.abs(x - x0) < 1e-3 || Math.abs(x - x1) < 1e-3 || Math.abs(y - y0) < 1e-3 || Math.abs(y - y1) < 1e-3
|
||||
);
|
||||
};
|
||||
return { boundary: (a, b) => on(a) && on(b) };
|
||||
}
|
||||
|
||||
/** 망(TIN 조각)의 씬 높이 조회기. */
|
||||
function tinHeights(out) {
|
||||
const p = out.root.getObjectByName("terrain-tin-mesh").geometry.getAttribute("position");
|
||||
const tris = [];
|
||||
for (let t = 0; t < p.count; t += 3) {
|
||||
tris.push([0, 1, 2].map((k) => [p.getX(t + k), p.getY(t + k), p.getZ(t + k)]));
|
||||
}
|
||||
return (sx, sz) => {
|
||||
for (const [a, b, c] of tris) {
|
||||
const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
|
||||
if (Math.abs(d) < 1e-12) continue;
|
||||
const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d;
|
||||
const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d;
|
||||
const w2 = 1 - w0 - w1;
|
||||
if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue;
|
||||
return w0 * a[1] + w1 * b[1] + w2 * c[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 한 번 돌리기 — 지형 · 노선 · 측점을 받아 결과와 검사 수치. */
|
||||
function run({ sections, route, terrainSpec, structures }) {
|
||||
const build = buildCorridor(sections, route);
|
||||
build.sections = sections;
|
||||
build.structures = structures ?? [];
|
||||
const { bounds, x0, x1, y0, y1, step, heightAt } = terrainSpec;
|
||||
const { terrain, positions, index } = gridTerrain(bounds, x0, x1, y0, y1, step, heightAt);
|
||||
const before = positions.slice();
|
||||
const split = new TerrainBandSplit(terrain, {
|
||||
minX: -1e4,
|
||||
maxX: 1e4,
|
||||
minZ: -1e4,
|
||||
maxZ: 1e4,
|
||||
});
|
||||
const heightIndex = new TerrainHeightIndex(terrain);
|
||||
const out = loftTerrain(split, build, bounds, heightIndex);
|
||||
return { out, before, positions, index, build, heightIndex };
|
||||
}
|
||||
|
||||
/** 틈 검사 — 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝(리본 이음) · 구멍 둘레가 아닌 것. */
|
||||
function gapEdges(tris, out, original) {
|
||||
const pslg = out.design.pslg;
|
||||
const tagAt = new Map();
|
||||
for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]);
|
||||
const uses = new Map();
|
||||
tris.forEach(({ tri }) => {
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
const a = tri[k];
|
||||
const b = tri[(k + 1) % 3];
|
||||
const ka = key(...a);
|
||||
const kb = key(...b);
|
||||
const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||
const entry = uses.get(e) ?? { n: 0, a, b };
|
||||
entry.n += 1;
|
||||
uses.set(e, entry);
|
||||
}
|
||||
});
|
||||
let gaps = 0;
|
||||
let maxGapLen = 0;
|
||||
for (const { n, a, b } of uses.values()) {
|
||||
if (n !== 1) continue;
|
||||
const ta = tagAt.get(pkey(a[0], a[2]));
|
||||
const tb = tagAt.get(pkey(b[0], b[2]));
|
||||
if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue;
|
||||
if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue;
|
||||
if (original.boundary(a, b)) continue;
|
||||
gaps += 1;
|
||||
maxGapLen = Math.max(maxGapLen, Math.hypot(a[0] - b[0], a[2] - b[2]));
|
||||
}
|
||||
return { gaps, maxGapLen };
|
||||
}
|
||||
|
||||
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
|
||||
function lostVertices(tris, before, out) {
|
||||
const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]);
|
||||
const border = new PSLG.SegGrid(2);
|
||||
let id = 0;
|
||||
[out.design.outer, ...out.design.holes].forEach((loop) => {
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
||||
border.add(id, loop[j][0], loop[j][1], loop[i][0], loop[i][1]);
|
||||
id += 1;
|
||||
}
|
||||
});
|
||||
const present = new Set();
|
||||
tris.forEach(({ tri }) => tri.forEach((p) => present.add(key(...p))));
|
||||
let lost = 0;
|
||||
for (let v = 0; v < before.length / 3; v += 1) {
|
||||
const x = before[v * 3];
|
||||
const z = before[v * 3 + 2];
|
||||
if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue;
|
||||
if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1;
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
|
||||
const results = {};
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
const STRAIGHT = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
];
|
||||
|
||||
/** 설계 구역 둘레의 비탈 끝 점(노면 끝 아님) — 모델 [x, y, z]. */
|
||||
function daylightPoints(out, bounds) {
|
||||
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 pslg = out.design.pslg;
|
||||
const pts = [];
|
||||
for (let i = 0; i < pslg.count; i += 1) {
|
||||
if (pslg.tag[i] !== PSLG.TAG_FILL && pslg.tag[i] !== PSLG.TAG_CUT) continue;
|
||||
pts.push({ x: pslg.x[i] + ox, y: -pslg.z[i] + oy, z: pslg.y[i] + oz, tag: pslg.tag[i], id: i });
|
||||
}
|
||||
// 둘레 = 같은 x(행)에서 가장 바깥 점.
|
||||
const outer = new Map();
|
||||
pts.forEach((p) => {
|
||||
const k = `${Math.round(p.x * 1e3)}:${p.y > 0 ? 1 : -1}`;
|
||||
const was = outer.get(k);
|
||||
if (!was || Math.abs(p.y) > Math.abs(was.y)) outer.set(k, p);
|
||||
});
|
||||
return [...outer.values()];
|
||||
}
|
||||
|
||||
// ① 직선 도로 · 측점 0 · 20 · 40(성토 1:1.5 · 지반 96 · 94 · 96) — 사이 비탈 끝 = 오프셋 직선 보간 ·
|
||||
// 측점 단면 = B06 · 드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 공사 밖 원래 그대로.
|
||||
{
|
||||
const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] };
|
||||
const base = (x) => (x <= 20 ? 96 - 0.1 * Math.max(x, 0) : 94 + 0.1 * Math.min(x - 20, 20));
|
||||
// 측점 자리(x = 0 · 20 · 40)에서 0 인 굴곡 — 측점 단면은 B06 지반(평탄)과 같고 사이는 출렁인다.
|
||||
const bump = (x, y) => 0.3 * Math.sin((Math.PI * x) / 20) * Math.cos(y / 4);
|
||||
const heightAt = (x, y) => base(x) + bump(x, y);
|
||||
const sections = [
|
||||
station(0, 0, 0, 0, 1, 96, 1.5),
|
||||
station(20, 20, 0, 0, 1, 94, 1.5),
|
||||
station(40, 40, 0, 0, 1, 96, 1.5),
|
||||
];
|
||||
const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt };
|
||||
const { out, before, positions, heightIndex } = run({ sections, route: STRAIGHT, terrainSpec: spec });
|
||||
const tris = trianglesOf(out.root);
|
||||
const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25));
|
||||
// 비탈 끝 오프셋 — 측점 사이 = 6 → 9 → 6(+ 노면 반폭) 직선 보간.
|
||||
let offsetErr = 0;
|
||||
let drapeErr = 0;
|
||||
let rowsChecked = 0;
|
||||
daylightPoints(out, bounds).forEach((p) => {
|
||||
if (p.x < 0 || p.x > 40) return;
|
||||
const w = p.x <= 20 ? 6 + (3 * p.x) / 20 : 9 - (3 * (p.x - 20)) / 20;
|
||||
offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w));
|
||||
const mesh = heightIndex.heightAt(p.x - 20, -p.y) + 100; // 망 지형(격자 삼각형) 높이
|
||||
drapeErr = Math.max(drapeErr, Math.abs(p.z - mesh));
|
||||
rowsChecked += 1;
|
||||
});
|
||||
// 측점 20 단면 — 망을 그 선에서 재어 B06 설계선(1:1.5 → 비탈 끝에서 지반)과 견준다.
|
||||
const tin = tinHeights(out);
|
||||
let sectionErr = 0;
|
||||
for (const side of [1, -1]) {
|
||||
for (let s = 0.05; s <= 12; s += 0.05) {
|
||||
const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20
|
||||
if (z === null) continue;
|
||||
sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94)));
|
||||
}
|
||||
}
|
||||
// 측점 사이(x = 10) 단면은 지형에 맞춰 다시 그리지 않는다 — 노면 끝 0 → 비탈 끝 1 비율 보정.
|
||||
results.straight = {
|
||||
stats: out.stats,
|
||||
gaps: gap.gaps,
|
||||
rowsChecked,
|
||||
offsetErr: r6(offsetErr),
|
||||
drapeErr: r6(drapeErr),
|
||||
sectionErr: r6(sectionErr),
|
||||
originalUntouched: before.every((value, i) => value === positions[i]),
|
||||
lostVerts: lostVertices(tris, before, out),
|
||||
};
|
||||
}
|
||||
|
||||
// ② 성토 ↔ 절토 — 측점 0 성토(지반 96) · 20 · 40 절토(지반 104). 바뀜은 평면도처럼 가운데(10)에서 끊음 ·
|
||||
// 비탈 끝 오프셋은 그대로 직선 보간 · 끊는 자리를 건너는 종방향 선 없음 · 틈 0.
|
||||
{
|
||||
const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] };
|
||||
const heightAt = (x) => 96 + 0.4 * Math.min(Math.max(x, 0), 20);
|
||||
const sections = [
|
||||
station(0, 0, 0, 0, 1, 96, 1.5),
|
||||
station(20, 20, 0, 0, 1, 104, 1.0),
|
||||
station(40, 40, 0, 0, 1, 104, 1.0),
|
||||
];
|
||||
const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt };
|
||||
const { out } = run({ sections, route: STRAIGHT, terrainSpec: spec });
|
||||
const tris = trianglesOf(out.root);
|
||||
const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25));
|
||||
const pts = daylightPoints(out, bounds);
|
||||
let wrongSide = 0;
|
||||
let offsetErr = 0;
|
||||
pts.forEach((p) => {
|
||||
if (p.x < 0 || p.x > 20) return;
|
||||
if (p.tag === PSLG.TAG_FILL && p.x >= 10 + 1e-6) wrongSide += 1;
|
||||
if (p.tag === PSLG.TAG_CUT && p.x < 10 - 0.2 - 1e-6) wrongSide += 1;
|
||||
const w = 6 + ((4 - 6) * p.x) / 20;
|
||||
offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w));
|
||||
});
|
||||
// 성토 점 ↔ 절토 점을 잇는 제약 변(노면 끝 · 지형 말고) — 끊었으면 횡방향으로도 없어야 한다.
|
||||
const pslg = out.design.pslg;
|
||||
let crossEdges = 0;
|
||||
for (let e = 0; e < pslg.kinds.length; e += 1) {
|
||||
const a = pslg.tag[pslg.edges[e * 2]];
|
||||
const b = pslg.tag[pslg.edges[e * 2 + 1]];
|
||||
if (pslg.kinds[e] !== PSLG.EDGE_DESIGN) continue;
|
||||
const fillCut = (a === PSLG.TAG_FILL && b === PSLG.TAG_CUT) || (a === PSLG.TAG_CUT && b === PSLG.TAG_FILL);
|
||||
const ax = pslg.x[pslg.edges[e * 2]] + 20;
|
||||
const bx = pslg.x[pslg.edges[e * 2 + 1]] + 20;
|
||||
if (fillCut && Math.abs(ax - bx) > 0.21) crossEdges += 1;
|
||||
}
|
||||
const fills = pts.filter((p) => p.tag === PSLG.TAG_FILL && p.x >= 0 && p.x <= 20).length;
|
||||
const cuts = pts.filter((p) => p.tag === PSLG.TAG_CUT && p.x >= 0 && p.x <= 20).length;
|
||||
results.flip = {
|
||||
stats: out.stats,
|
||||
gaps: gap.gaps,
|
||||
wrongSide,
|
||||
fills,
|
||||
cuts,
|
||||
crossEdges,
|
||||
offsetErr: r6(offsetErr),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 짝 맞추기 — 소단 둘 단면 대 소단 하나 단면 · 같은 점 수 · 첫 꺾임끼리 짝.
|
||||
{
|
||||
const a = { kind: 0, pts: Float64Array.from([0, 0, 6, -4, 7.5, -4, 13.5, -8, 15, -8, 18, -10]) };
|
||||
const b = { kind: 0, pts: Float64Array.from([0, 0, 3, -2, 4.5, -2, 9, -5]) };
|
||||
const [pa, pb] = pairProfiles(a, b);
|
||||
const idxOf = (p, s, z) => {
|
||||
for (let k = 0; k < p.length / 2; k += 1) {
|
||||
if (Math.abs(p[k * 2] - s) < 1e-9 && Math.abs(p[k * 2 + 1] - z) < 1e-9) return k;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const [pn, pq] = pairProfiles(null, b);
|
||||
results.pair = {
|
||||
sameCount: pa.length === pb.length,
|
||||
firstBend: idxOf(pa, 6, -4) === idxOf(pb, 3, -2) && idxOf(pa, 6, -4) > 0,
|
||||
secondBend: idxOf(pa, 7.5, -4) === idxOf(pb, 4.5, -2),
|
||||
ends: pa[pa.length - 2] === 18 && pb[pb.length - 2] === 9,
|
||||
nullCollapses: pn.length === pq.length && pn.every((v) => v === 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 급곡선(R 12 m) · 높은 성토(8 m · 비탈 12 m) — 안쪽 단면 겹침 청소 · 둘레 꼬임 없음 · 틈 0.
|
||||
{
|
||||
const R = 12;
|
||||
const route = [];
|
||||
for (let a = 0; a <= 180; a += 2) {
|
||||
const t = (a * Math.PI) / 180;
|
||||
route.push({ x: R * Math.sin(t), y: R - R * Math.cos(t) });
|
||||
}
|
||||
const sections = [0, 30, 60, 90, 120, 150, 180].map((deg) => {
|
||||
const t = (deg * Math.PI) / 180;
|
||||
return station(R * t, R * Math.sin(t), R - R * Math.cos(t), -Math.sin(t), Math.cos(t), 92, 1.5);
|
||||
});
|
||||
const bounds = { x: [-40, 40], y: [-30, 50], z: [80, 110] };
|
||||
const { out } = run({
|
||||
sections,
|
||||
route,
|
||||
terrainSpec: { bounds, x0: -40, x1: 40, y0: -30, y1: 50, step: 1, heightAt: () => 92 },
|
||||
});
|
||||
const outer = out.design.outer;
|
||||
let selfCross = 0;
|
||||
for (let i = 0; i < outer.length; i += 1) {
|
||||
const a = outer[i];
|
||||
const b = outer[(i + 1) % outer.length];
|
||||
for (let j = i + 2; j < outer.length; j += 1) {
|
||||
if ((j + 1) % outer.length === i) continue;
|
||||
const c = outer[j];
|
||||
const d = outer[(j + 1) % outer.length];
|
||||
if (PSLG.crossing(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1])) selfCross += 1;
|
||||
}
|
||||
}
|
||||
const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -40, 40, -30, 50));
|
||||
results.curve = { stats: out.stats, selfCross, gaps: gap.gaps };
|
||||
}
|
||||
|
||||
// ⑤ 소단 단면 — 측점 0 · 40 소단(1:1.5 로 4 m · 소단 1.5 m · 다시 1:1.5) · 20 은 소단 없음 → 꺾임 종방향 선 · 틈 0.
|
||||
{
|
||||
const bounds = { x: [-10, 50], y: [-30, 30], z: [85, 110] };
|
||||
const berm = (c) => {
|
||||
const st = station(c, c, 0, 0, 1, 92, 1.5);
|
||||
const half = [
|
||||
[HALF, ROAD_Z],
|
||||
[HALF + 6, ROAD_Z - 4],
|
||||
[HALF + 7.5, ROAD_Z - 4],
|
||||
[HALF + 13.5, 92],
|
||||
[40, 92],
|
||||
];
|
||||
const pts = [...half.map(([o, z]) => [-o, z]).reverse(), [-2.5, ROAD_Z], [2.5, ROAD_Z], ...half];
|
||||
st.design.design_line = pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m }));
|
||||
return st;
|
||||
};
|
||||
const sections = [berm(0), station(20, 20, 0, 0, 1, 92, 1.5), berm(40)];
|
||||
const { out } = run({
|
||||
sections,
|
||||
route: STRAIGHT,
|
||||
terrainSpec: { bounds, x0: -10, x1: 50, y0: -30, y1: 30, step: 0.5, heightAt: () => 92 },
|
||||
});
|
||||
const line = sections[0].design.design_line;
|
||||
const b06 = (o) => {
|
||||
for (let i = 0; i + 1 < line.length; i += 1) {
|
||||
const a = line[i];
|
||||
const b = line[i + 1];
|
||||
if (o >= a.offset_m && o <= b.offset_m) {
|
||||
const t = (o - a.offset_m) / (b.offset_m - a.offset_m || 1);
|
||||
return Math.max(92, a.elevation_m + (b.elevation_m - a.elevation_m) * t);
|
||||
}
|
||||
}
|
||||
return 92;
|
||||
};
|
||||
const tin = tinHeights(out);
|
||||
let sectionErr = 0;
|
||||
for (let o = HALF + 0.05; o <= HALF + 16; o += 0.05) {
|
||||
for (const side of [1, -1]) {
|
||||
const z = tin(-20, -side * o); // 측점 0 = 씬 x −20
|
||||
if (z !== null) sectionErr = Math.max(sectionErr, Math.abs(z + 97.5 - b06(side * o)));
|
||||
}
|
||||
}
|
||||
const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -10, 50, -30, 30));
|
||||
results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps };
|
||||
}
|
||||
|
||||
// ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록.
|
||||
{
|
||||
globalThis.window = globalThis.window || {};
|
||||
const Modes = loadTs(B05("B05_Profile_UI_Corridor_Modes"));
|
||||
const mode = Modes.modeOf("proposal3");
|
||||
results.registry = {
|
||||
slot: Modes.MODE_SLOTS.some(([id, label]) => id === "proposal3" && label === "제안 ③"),
|
||||
registered: Boolean(mode),
|
||||
hasTerrain: typeof mode?.terrain === "function",
|
||||
noIndex: mode?.terrain ? mode.terrain({ heightIndex: null }) : "none",
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -69,8 +69,20 @@ def test_structure_top_hole_and_drape(results):
|
||||
|
||||
def test_mode_registry_and_migration(results):
|
||||
r = results["modes"]
|
||||
assert r["slots"] == ["current:지금", "legacy-a:옛 A", "new1:새 ①", "proposal2:제안 ②"]
|
||||
assert r["registered"] == ["current", "legacy-a", "new1", "proposal2"] # 제안 ② 등록(115-6)
|
||||
assert r["slots"] == [
|
||||
"current:지금",
|
||||
"legacy-a:옛 A",
|
||||
"new1:새 ①",
|
||||
"proposal2:제안 ②",
|
||||
"proposal3:제안 ③",
|
||||
]
|
||||
assert r["registered"] == [
|
||||
"current",
|
||||
"legacy-a",
|
||||
"new1",
|
||||
"proposal2",
|
||||
"proposal3",
|
||||
] # 제안 ② · ③ 등록(115-6 · 115-8)
|
||||
assert r["defaultMode"] == "new1" and r["fresh"] == "new1"
|
||||
assert r["newHasTerrain"] is True
|
||||
assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 115-8 — B05 3D 제안 ③ 측점 횡단 기반 사면 검사.
|
||||
|
||||
Node 도우미(helper_115_8_corridor_loft.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
|
||||
결과(JSON)를 단언한다. 사이 비탈 끝 = 오프셋 직선 보간 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 ·
|
||||
드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_115_8_corridor_loft.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_between_stations_daylight_is_offset_lerp(results):
|
||||
r = results["straight"]
|
||||
assert r["rowsChecked"] > 300
|
||||
assert r["offsetErr"] < 1e-4 # 비탈 끝 = 측점 오프셋 6 → 9 → 6 직선 보간(평면도 외곽선)
|
||||
|
||||
|
||||
def test_station_section_matches_b06(results):
|
||||
assert results["straight"]["sectionErr"] < 1e-4 # 측점 20 단면 = 성토 1:1.5 → 지반
|
||||
assert results["berm"]["sectionErr"] < 1e-4 # 소단 꼭짓점 그대로(재표본 없음)
|
||||
|
||||
|
||||
def test_daylight_draped_on_terrain(results):
|
||||
assert results["straight"]["drapeErr"] < 1e-4 # 비탈 끝 높이 = 망 지형
|
||||
|
||||
|
||||
def test_no_gap_and_terrain_outside_untouched(results):
|
||||
for name in ("straight", "flip", "curve", "berm"):
|
||||
assert results[name]["gaps"] == 0, name
|
||||
assert results[name]["stats"]["failed"] == 0, name
|
||||
r = results["straight"]
|
||||
assert r["originalUntouched"] is True and r["lostVerts"] == 0
|
||||
|
||||
|
||||
def test_fill_cut_change_breaks_like_plan(results):
|
||||
r = results["flip"]
|
||||
assert r["fills"] > 0 and r["cuts"] > 0
|
||||
assert r["wrongSide"] == 0 # 바뀜 = 두 측점 가운데(10 m)
|
||||
assert r["crossEdges"] == 0 # 끊는 자리를 건너는 종방향 선 없음
|
||||
assert r["offsetErr"] < 1e-4 # 비탈 끝 오프셋은 그대로 보간
|
||||
|
||||
|
||||
def test_curve_bowtie_cleared(results):
|
||||
r = results["curve"]
|
||||
assert r["stats"]["bowtie"] > 0 and r["selfCross"] == 0
|
||||
|
||||
|
||||
def test_pairing_bends(results):
|
||||
r = results["pair"]
|
||||
assert all(r.values()), r
|
||||
assert results["berm"]["stats"]["bendLines"] > 0 # 같은 번째 꼭짓점 종방향 선
|
||||
|
||||
|
||||
def test_registered_as_proposal3(results):
|
||||
r = results["registry"]
|
||||
assert r["slot"] is True and r["registered"] is True and r["hasTerrain"] is True
|
||||
assert r["noIndex"] is None
|
||||
Reference in New Issue
Block a user