fix(B05): 3D 새 방식 비탈 폭주 막음 · 코리도 행 자리 노선 길이 보간(115-5 P0)

- 비탈 끝 못 찾으면 단면 끝까지만 · 높이 변화 단면 높이 + 1 m 상한 · 조각은 앞뒤 측점 중 하나라도 가진 행에만 · auto 실루엣 종류는 기울기로
- 노선 표본을 노선 길이 직선 보간으로(균일 Catmull-Rom 미끄러짐 · 되접힘 제거) · BUILD_VERSION 93(저장본 · 서버 사전생성 다시 만들어짐)
- 226 실측: 최대 깎임 171 → 8.1 m · 10 m 넘게 내려간 정점 1,797 → 0 · 행 자리 최대 7.23 → 0.3 m · 되접힘 18 → 0 · 이음 틈 2.43 → 0.05 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 21:56:07 +09:00
co-authored by Claude Opus 5.5
parent b6f0a70645
commit 8bbd9607b8
6 changed files with 350 additions and 56 deletions
+92 -14
View File
@@ -11,6 +11,8 @@
* · 노면부 바깥에서 `z' = clamp(지형 z, 성토선 z, 절토선 z)`. 선 = 앵커 + 측점 사이
* 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장.
* · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반.
* 못 찾으면 단면 끝까지만(115-5 P0-1) · 높이 변화는 단면 높이 + 1 m 까지.
* · 조각은 앞뒤 측점 중 하나라도 가진 행에만(`_Build.ts` 와 같은 규칙).
* · XY 는 움직이지 않는다.
* ========================================================================== */
@@ -35,6 +37,10 @@ export type DeformKind = "cut" | "fill";
/** 비탈 끝 찾기 한계(m) · 걸음(m) — 밴드 여유(80 m) 안쪽. */
export const MAX_REACH_M = 60;
const SCAN_STEP_M = 0.25;
/** 높이 변화 안전 상한 = 단면 높이 + 이 값(m) — 폭주 구덩이 막음(115-5 P0-1 ④). */
const SAFETY_M = 1;
/** 꺾임 브레이크라인으로 세울 단면 꺾임각(도). */
const BEND_DEG = 8;
/** 이 안쪽 정점은 앵커 높이 그대로(노면부 끝 경계 정점). */
const BOUNDARY_EPS_M = 0.01;
/** 역투영 되짚기 허용 오차(m) — 넘으면 곡선 안쪽 모호 자리로 보고 손대지 않는다. */
@@ -99,11 +105,24 @@ export interface DeformLocation {
outward: number;
}
/** 변형 결과 — 높이와 자리(0 원지반 · 1 성토 · 2 절토). 색은 자리로 칠한다. */
export interface DeformPoint {
z: number;
zone: 0 | 1 | 2;
}
export interface DeformField {
rows: DeformRow[];
locate: (x: number, y: number) => DeformLocation | null;
/** 변형된 z — 대상이 아니면 받은 z 그대로. */
deformZ: (x: number, y: number, z: number) => number;
deformAt: (x: number, y: number, z: number) => DeformPoint;
/** 행 · 측(0 좌 · 1 우) · 종류(0 성토 · 1 절토)의 비탈 끝 바깥거리(m) — 0 이면 비탈 없음. */
catchAt: (row: number, side: number, kind: number) => number;
/** 비탈 끝 안 단면 꺾임 — [열 번호, 바깥거리] (브레이크라인). */
bendsAt: (row: number, side: number, kind: number) => ReadonlyArray<[number, number]>;
/** 행 앵커에서 바깥으로 s 만큼 간 평면 자리(모델 좌표). */
pointAt: (row: number, side: number, s: number) => [number, number];
}
/** 단면 높이 — s 가 끝을 넘으면 마지막 구배로 늘린다(`noExtend`면 null). */
@@ -229,15 +248,29 @@ export function stationProfileSource(
);
}),
);
const pick = (station: StationPieces, side: DeformSide, kind: DeformKind) =>
slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(pieceKey(kind, side));
/** 행을 감싼 앞뒤 측점 — 범위 밖은 끝 측점. */
const around = (chainageM: number): [StationPieces, StationPieces] => {
let i = 0;
while (i < stations.length - 2 && stations[i + 1].chainage_m < chainageM) i += 1;
const next = stations[Math.min(i + 1, stations.length - 1)];
if (chainageM <= stations[i].chainage_m) return [stations[i], stations[i]];
return chainageM >= next.chainage_m ? [next, next] : [stations[i], next];
};
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;
if (!stations.length) return null;
// 앞뒤 측점 둘 다 그 조각이 없으면 이 행에는 없다(`_Build.ts` 규칙 · 115-5 P0-1 ②).
const [s0, s1] = around(chainageM);
const own0 = pick(s0, side, kind);
const own1 = pick(s1, side, kind);
if (!own0 && !own1) return null;
const list = controls.get(pieceKey(kind, side));
// 그 측 그 종류를 가진 측점이 하나뿐 — 그 단면 그대로.
if (!list) return outwardProfile((own0 ?? own1) as OffsetPoint[], side);
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 outwardProfile(points, side);
};
return (chainageM, side) => {
const fill = at(side, "fill", chainageM);
@@ -289,6 +322,8 @@ export function buildDeformField(
/** [행 × 측 × 종류] 단면 · 비탈 끝 거리. 측 = 0 좌 · 1 우, 종류 = 0 성토 · 1 절토. */
const profiles: Array<DeformProfile | null> = new Array(n * 4).fill(null);
const catches = new Float64Array(n * 4);
const heights = new Float64Array(n * 4);
const bends: Array<Array<[number, number]>> = new Array(n * 4);
const noExtend = new Uint8Array(n * 2);
let reachMax = 0;
rows.forEach((row, r) => {
@@ -307,8 +342,14 @@ export function buildDeformField(
noExtend[r * 2 + si] = shapes.noExtend ? 1 : 0;
KINDS.forEach((kind, ki) => {
const profile = shapes[kind];
profiles[r * 4 + si * 2 + ki] = profile;
const slot = r * 4 + si * 2 + ki;
profiles[slot] = profile;
bends[slot] = [];
if (!profile) return;
const count = profile.length / 2;
for (let i = 0; i < count; i += 1) {
heights[slot] = Math.max(heights[slot], Math.abs(profile[i * 2 + 1]));
}
// 비탈 끝 = 앵커에서 바깥으로 걸어 지반과 처음 만나는 자리. 성토는 지반이 선 위로,
// 절토는 아래로 넘어가는 곳. 앵커에서 이미 반대편이면 그 종류는 이 행에 없다(0).
const beyond = (s: number): number | null => {
@@ -322,22 +363,34 @@ export function buildDeformField(
let previous = beyond(0);
let reach = 0;
if (previous !== null && previous > 0) {
let found = false;
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;
found = true;
break;
}
if (now <= 0) {
reach = s - SCAN_STEP_M + (SCAN_STEP_M * previous) / (previous - now);
found = true;
break;
}
previous = now;
reach = s;
}
// 못 찾으면 단면 끝까지만 — 마지막 구배로 60 m 까지 깎던 폭주 구덩이(115-5 P0-1 ①).
if (!found) reach = profile[(count - 1) * 2];
}
catches[r * 4 + si * 2 + ki] = reach;
catches[slot] = reach;
if (reach > reachMax) reachMax = reach;
// 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만.
for (let i = 1; i < count - 1; i += 1) {
const s = profile[i * 2];
if (s >= reach) break;
const a0 = Math.atan2(profile[i * 2 + 1] - profile[i * 2 - 1], s - profile[i * 2 - 2]);
const a1 = Math.atan2(profile[i * 2 + 3] - profile[i * 2 + 1], profile[i * 2 + 2] - s);
if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) bends[slot].push([i, s]);
}
});
});
});
@@ -432,15 +485,28 @@ export function buildDeformField(
if (!profile || s >= catches[slot]) return;
const rel = profileZ(profile, s, free);
if (rel === null) return;
const line = anchor[2] + rel;
let line = anchor[2] + rel;
// 안전 상한(115-5 P0-1 ④) — 단면 높이 + 1 m 넘게는 옮기지 않는다. 구조물에서 끝나는
// 단면(BOX)은 지반까지 안 내려가 단면 높이가 기준이 못 된다.
const cap = heights[slot] + SAFETY_M;
if (!free && Math.abs(line - z) > cap) line = z + Math.sign(line - z) * cap;
if (kind === "fill" ? out < line : out > line) out = line;
});
return out;
}
function deformZ(x: number, y: number, z: number): number {
/** 그 자리의 비탈 종류 — 앞뒤 행 중 하나라도 비탈 끝 안이면 그 종류. */
function zoneOf(r: number, si: number, s: number): 0 | 1 | 2 {
for (const k of [r, r + 1]) {
if (s < catches[k * 4 + si * 2]) return 1;
if (s < catches[k * 4 + si * 2 + 1]) return 2;
}
return 0;
}
function deformAt(x: number, y: number, z: number): DeformPoint {
const at = locate(x, y);
if (!at || at.outward > reachMax) return z;
if (!at || at.outward > reachMax) return { z, zone: 0 };
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;
@@ -449,8 +515,20 @@ export function buildDeformField(
out = Math.min(out, Math.max(z, limit.ceiling ?? z));
}
}
return out;
return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s) };
}
return { rows, locate, deformZ };
return {
rows,
locate,
deformAt,
deformZ: (x, y, z) => deformAt(x, y, z).z,
catchAt: (r, si, ki) => catches[r * 4 + si * 2 + ki],
bendsAt: (r, si, ki) => bends[r * 4 + si * 2 + ki] ?? [],
pointAt: (r, si, s) => {
const anchor = si === 0 ? rows[r].left : rows[r].right;
const sign = si === 0 ? 1 : -1;
return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s];
},
};
}
@@ -117,13 +117,20 @@ export function structureSlopes(sections: ReadonlyArray<CrossSection>): Structur
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;
if (!anchor || !silhouette.points.length) continue;
// auto 는 기울기로 — 바깥 끝이 앵커보다 낮으면 성토(115-5 P0-1 ③). 단면 모드로 가르면
// both_cut 유출측 내려가는 선이 절토가 돼 구덩이를 팠다.
const sign = side === "left" ? 1 : -1;
const outer = silhouette.points.reduce((best, point) =>
(point.offset_m - best.offset_m) * sign > 0 ? point : best,
);
const kind: DeformKind =
silhouette.kind === "auto"
? outer.elevation_m < anchor.elevation_m
? "fill"
: "cut"
: silhouette.kind;
const points = silhouetteSlope(silhouette.points, side, anchor, kind);
if (!points) continue;
slopes.push({
@@ -201,7 +201,9 @@ function fnv1a(text: string): string {
// 부재를 못 정하므로 만료한다.
// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
const BUILD_VERSION = 92;
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
const BUILD_VERSION = 93;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+6 -35
View File
@@ -101,38 +101,12 @@ export function bracketControls(
return { a: last, b: last, t: 0 };
}
/**
* Centripetal Catmull-Rom 한 구간(p1→p2). 제어점을 지나면서 오버슈트·자기교차가
* 없는 성질이라 급커브(실측 꺾임각 최대 42°)에도 안전하다.
*/
function catmullRom(p0: XY, p1: XY, p2: XY, p3: XY, t: number): XY {
const ALPHA = 0.5; // centripetal
const knot = (previous: number, a: XY, b: XY): number =>
previous + Math.max(1e-6, Math.pow(Math.hypot(b.x - a.x, b.y - a.y), ALPHA));
const t0 = 0;
const t1 = knot(t0, p0, p1);
const t2 = knot(t1, p1, p2);
const t3 = knot(t2, p2, p3);
const time = t1 + (t2 - t1) * t;
const mix = (a: XY, b: XY, ta: number, tb: number): XY => {
const span = tb - ta || 1e-9;
const w = (tb - time) / span;
return { x: a.x * w + b.x * (1 - w), y: a.y * w + b.y * (1 - w) };
};
const a1 = mix(p0, p1, t0, t1);
const a2 = mix(p1, p2, t1, t2);
const a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2);
const b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
/**
* 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 돌려준다.
*
* 정점 사이를 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다 — 0.1m로 잘게
* 쪼개도 그 각짐은 그대로다(2026-08-23 사용자 지적). 그래서 **스플라인**으로 잇고
* 세분 간격은 0.2m로 되돌린다: 데이터는 절반, 곡선은 훨씬 매끄럽다.
* 노선 길이 그대로 직선 보간한다(115-5 P0-2) — B06 측점 중심과 0 m 일치. 균일
* Catmull-Rom 은 구간 길이(1.2~93 m)가 섞이면 매개변수가 틀어져 행이 최대 7.2 m
* 미끄러지고 되접혔다. 곡선부 노선점은 중앙 1.18 m 간격이라 각짐이 작다.
*/
export function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) => XY) | null {
const points = routePoints.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
@@ -143,18 +117,15 @@ export function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: num
cumulative[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y),
);
}
const at = (index: number): XY => {
const clamped = Math.min(points.length - 1, Math.max(0, index));
return { x: points[clamped].x, y: points[clamped].y };
};
return (chainage: number): XY => {
const target = Math.min(Math.max(chainage, 0), cumulative[cumulative.length - 1]);
let index = 1;
while (index < points.length - 1 && cumulative[index] < target) index += 1;
const span = cumulative[index] - cumulative[index - 1];
const t = span <= 1e-12 ? 0 : (target - cumulative[index - 1]) / span;
// 양 끝 구간은 바깥 제어점이 없다 — 끝점을 겹쳐 써서 접선이 튀지 않게 한다.
return catmullRom(at(index - 2), at(index - 1), at(index), at(index + 1), t);
const a = points[index - 1];
const b = points[index];
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
};
}
@@ -0,0 +1,190 @@
/* PLAN 115-5 단위검증 헬퍼 — 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 { buildPolylineSampler } = loadTs(B05("B05_Profile_UI_Corridor_Frames"));
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",
},
};
}
function routeOf(sections) {
const last = sections[sections.length - 1].chainage_m;
return [
{ x: 0, y: 0 },
{ x: last, y: 0 },
];
}
function fieldOf(sections, groundAt) {
const build = buildCorridor(sections, routeOf(sections));
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt);
return { build, field };
}
const results = {};
// ① 폭주 0(P0-1 ① ④) — 지반이 성토선보다 가파르게 떨어져 끝내 안 만난다. 옛 규칙은 60 m 까지
// 마지막 구배로 메웠다. 새 규칙은 단면 끝(6 m)까지만 · 높이 변화는 단면 높이(4) + 1 m 까지.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const groundAt = (x, y) => 96 - 2 * Math.max(0, Math.abs(y) - HALF);
const { field } = fieldOf(sections, groundAt);
let maxDelta = 0;
for (let s = 0.25; s <= 40; s += 0.25) {
const y = HALF + s;
maxDelta = Math.max(
maxDelta,
Math.abs(field.deformZ(10, y, groundAt(10, y)) - groundAt(10, y)),
);
}
const far = HALF + 20;
results.runaway = {
catch: r4(field.catchAt(50, 0, 0)),
far: r4(field.deformZ(10, far, groundAt(10, far)) - groundAt(10, far)),
maxDelta: r4(maxDelta),
};
}
// ② 조각 규칙(P0-1 ②) — 20 · 40 측점은 절토(성토 조각 없음). 그 사이 행은 0 · 60 측점
// 성토를 끌어와 메우지 않는다.
{
const sections = [
station(0, "both_fill", 96, 1.5),
station(20, "both_cut", 104, 1.0),
station(40, "both_cut", 104, 1.0),
station(60, "both_fill", 96, 1.5),
];
const { field } = fieldOf(sections, () => 96);
results.pieceRule = {
between: r4(field.deformZ(30, HALF + 2, 96)),
nearFill: r4(field.deformZ(5, HALF + 2, 96)),
};
}
// ③ 행 자리(P0-2) — 구간 길이가 1.2 m 와 93 m 로 섞인 노선. 표본 점은 노선 위 · 노선 길이 =
// 누가거리(0 m 어긋남).
{
const route = [
{ x: 0, y: 0 },
{ x: 1.2, y: 0 },
{ x: 2.3, y: 0.4 },
{ x: 95, y: 12 },
{ x: 96.1, y: 12.6 },
{ x: 97.1, y: 13.5 },
{ x: 98, y: 14.6 },
];
const cum = [0];
for (let i = 1; i < route.length; i += 1) {
cum.push(cum[i - 1] + Math.hypot(route[i].x - route[i - 1].x, route[i].y - route[i - 1].y));
}
const sampler = buildPolylineSampler(route);
let alongMax = 0;
let lateralMax = 0;
let folds = 0;
let previous = -Infinity;
for (let c = 0; c <= cum[cum.length - 1]; c += 0.2) {
const p = sampler(c);
let best = Infinity;
let along = 0;
for (let i = 1; i < route.length; i += 1) {
const ax = route[i - 1].x;
const ay = route[i - 1].y;
const dx = route[i].x - ax;
const dy = route[i].y - ay;
const l2 = dx * dx + dy * dy;
const t = Math.max(0, Math.min(1, ((p.x - ax) * dx + (p.y - ay) * dy) / l2));
const d = Math.hypot(p.x - ax - dx * t, p.y - ay - dy * t);
if (d < best) {
best = d;
along = cum[i - 1] + Math.sqrt(l2) * t;
}
}
alongMax = Math.max(alongMax, Math.abs(along - c));
lateralMax = Math.max(lateralMax, best);
if (along < previous - 1e-9) folds += 1;
previous = along;
}
results.rowPlace = { alongMax, lateralMax, folds };
}
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""PLAN 115-5 — B05 3D 제안 ① 새 방식 개선 합성 검사.
Node 도우미(helper_115_5_corridor_improve.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m.
"""
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_5_corridor_improve.cjs")],
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_runaway_slope_stops_at_section_end(results):
r = results["runaway"]
assert r["catch"] == 6 # 못 찾으면 단면 끝(6 m)까지만
assert r["far"] == 0 # 단면 끝 너머 지형은 그대로
assert r["maxDelta"] <= 4 + 1 # 단면 높이 + 1 m 상한
def test_piece_only_where_neighbour_station_has_it(results):
r = results["pieceRule"]
assert r["between"] == 96 # 앞뒤 측점 둘 다 성토 없음 — 메우지 않음
assert r["nearFill"] > 96 # 성토 측점 곁은 그대로 메움
def test_row_place_follows_route_length(results):
r = results["rowPlace"]
assert r["alongMax"] < 1e-6 # 행 자리 = 누가거리(0 m)
assert r["lateralMax"] < 1e-6 # 노선 위
assert r["folds"] == 0