fix(B05): 3D 성토 비탈 끝 종단 이음 — 튐 · 못 닫힘 보간 · 측점 닻 원뿔 · 비탈 끝 밖 2 m 덧댐(115-7)

- 공통 변형 장(buildDeformField) 성토 비탈 끝 열을 측마다 종단으로 이음(새 ① · 제안 ② 같이 받음)
- 못 닫힘 = 단면 끝 너머 max(2 m, 25 %) 넘어서야 닫힌 성토 · 끝까지 못 찾음
- 튐 = 이웃 ±3 m 중앙값과 max(2 m, 25 %) 넘게 다름 → 이웃 좋은 행 보간
- 원뿔 넓히기 1 m/m · 늘린 몫은 측점 값 + 1 m/m 까지 · 측점 행 날것 · 0 행 끊음(단면 있는 3 m 안 틈만 이음)
- 새 ① 바뀐 행은 비탈 끝 밖 2 m 를 비탈 끝 높이 → 원지반으로 덧댐 · 절토 그대로
- 시험 test_115_7_fill_continuity(직각 골짜기 · 계단 지형 · 못 닫힘 · 측점 행 · 절토)

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-09 08:25:51 +09:00
co-authored by Claude Opus 5.5
parent a23aea6ed1
commit 35ab4bd48e
3 changed files with 447 additions and 12 deletions
+169 -12
View File
@@ -12,6 +12,8 @@
* 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장.
* · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반.
* 못 찾으면 단면 끝까지만(115-5 P0-1) · 높이 변화는 단면 높이 + 1 m 까지.
* · 성토 비탈 끝은 종단으로 잇는다(115-7 smoothFillCatches) — 튐 · 못 닫힘 보간 · 원뿔 넓히기 ·
* 바뀐 행은 비탈 끝 밖 2 m 덧댐.
* · 조각은 앞뒤 측점 중 하나라도 가진 행에만(`_Build.ts` 와 같은 규칙).
* · XY 는 움직이지 않는다.
* ========================================================================== */
@@ -50,6 +52,15 @@ const GRID_M = 4;
const SEED_EVERY = 5;
const WALK_LIMIT = 400;
// ponytail: 성토 비탈 끝 종단 이음(115-7) 고정 값 — 이웃 ±3 m 중앙값 · 튐 = max(2 m, 25 %) ·
// 원뿔 1 m/m · 비탈 끝 밖 덧댐 2 m · 이을 짧은 틈 3 m. 지형마다 안 맞으면 여기서 조절.
const FILL_WINDOW_M = 3;
const FILL_JUMP_M = 2;
const FILL_JUMP_RATIO = 0.25;
const FILL_CONE = 1;
const FILL_SKIRT_M = 2;
const FILL_BRIDGE_M = 3;
const SIDES: readonly DeformSide[] = ["left", "right"];
const KINDS: readonly DeformKind[] = ["fill", "cut"];
@@ -290,6 +301,105 @@ export function stationProfileSource(
};
}
/**
* 성토 비탈 끝 종단 이음(115-7) — 한 측 성토 열. 행마다 따로 찾은 비탈 끝이 이웃과 튀면(골짜기 ·
* 계단 DTM · 못 닫힘) 두 행 혼합이 행 사이에 수직 벽(지느러미 · 빗살 구덩이)을 세운다.
* 튐 · 못 닫힘 행 → 이웃 좋은 행 보간 → 원뿔 넓히기(이웃 차 1 m/m 안). 측점 행은 날것(못 닫힘 빼고).
* 비탈 끝 0 행은 구간을 끊는다 — 단면은 있는데 앵커에서 막힌 3 m 안 틈만 잇는다.
* `open` = 못 닫힘 · `station` = 측점 행 · `shaped` = 그 행에 성토 단면 있음(모두 0/1).
*/
export function smoothFillCatches(
chainages: ArrayLike<number>,
raw: ArrayLike<number>,
open: ArrayLike<number>,
station: ArrayLike<number>,
shaped: ArrayLike<number>,
): Float64Array {
const n = raw.length;
const out = Float64Array.from(raw);
const on = new Uint8Array(n);
const bridged = new Uint8Array(n);
for (let r = 0; r < n; r += 1) on[r] = raw[r] > 0 ? 1 : 0;
for (let r = 1; r < n; r += 1) {
if (on[r] || !on[r - 1]) continue;
let e = r;
while (e < n && !on[e] && shaped[e]) e += 1;
if (e < n && on[e] && chainages[e - 1] - chainages[r] <= FILL_BRIDGE_M) {
for (let k = r; k < e; k += 1) on[k] = bridged[k] = 1;
}
r = e;
}
for (let start = 0; start < n;) {
if (!on[start]) {
start += 1;
continue;
}
let end = start;
while (end + 1 < n && on[end + 1]) end += 1;
const trusted = (k: number) => !open[k] && !bridged[k];
const good = new Uint8Array(end - start + 1);
let lo = start;
let hi = start;
for (let r = start; r <= end; r += 1) {
if (station[r] && trusted(r)) {
good[r - start] = 1;
continue;
}
while (chainages[lo] < chainages[r] - FILL_WINDOW_M) lo += 1;
while (hi < end && chainages[hi + 1] <= chainages[r] + FILL_WINDOW_M) hi += 1;
const near: number[] = [];
for (let k = lo; k <= hi; k += 1) if (trusted(k)) near.push(raw[k]);
if (!near.length) {
good[r - start] = trusted(r) ? 1 : 0;
continue;
}
near.sort((a, b) => a - b);
const median = near[near.length >> 1];
const jump = Math.max(FILL_JUMP_M, median * FILL_JUMP_RATIO);
good[r - start] = trusted(r) && Math.abs(raw[r] - median) <= jump ? 1 : 0;
}
// 튐 · 못 닫힘 — 앞뒤 좋은 행을 노선 길이로 보간(한쪽뿐이면 그 값 · 없으면 날것).
let prev = -1;
for (let r = start; r <= end; r += 1) {
if (good[r - start]) {
prev = r;
continue;
}
let next = r + 1;
while (next <= end && !good[next - start]) next += 1;
if (prev >= 0 && next <= end) {
const t = (chainages[r] - chainages[prev]) / (chainages[next] - chainages[prev] || 1);
out[r] = raw[prev] + (raw[next] - raw[prev]) * t;
} else if (prev >= 0) out[r] = raw[prev];
else if (next <= end) out[r] = raw[next];
}
// 원뿔 넓히기 — c'_r = max_j(c_j − 1·|s_r − s_j|) 를 앞뒤 두 번 훑어 낸다. 늘린 몫은 측점 닻
// (측점 값 + 1 m/m)까지만 — 먼 큰 값이 측점까지 번지지 않게. 보간 값보다 줄이지는 않는다.
const base = out.slice(start, end + 1);
const cap = new Float64Array(end - start + 1).fill(Infinity);
for (let r = start + 1; r <= end; r += 1) {
out[r] = Math.max(out[r], out[r - 1] - FILL_CONE * (chainages[r] - chainages[r - 1]));
}
for (let r = end - 1; r >= start; r -= 1) {
out[r] = Math.max(out[r], out[r + 1] - FILL_CONE * (chainages[r + 1] - chainages[r]));
}
for (const step of [1, -1]) {
let anchor = -1;
for (let r = step > 0 ? start : end; r >= start && r <= end; r += step) {
if (station[r] && trusted(r)) anchor = r;
if (anchor < 0) continue;
const reach = raw[anchor] + FILL_CONE * Math.abs(chainages[r] - chainages[anchor]);
cap[r - start] = Math.min(cap[r - start], reach);
}
}
for (let r = start; r <= end; r += 1) {
out[r] = Math.max(base[r - start], Math.min(out[r], cap[r - start]));
}
start = end + 1;
}
return out;
}
/** 점이 고리 안인가(광선 투사). */
function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
let hit = false;
@@ -325,6 +435,8 @@ export function buildDeformField(
const heights = new Float64Array(n * 4);
const bends: Array<Array<[number, number]>> = new Array(n * 4);
const noExtend = new Uint8Array(n * 2);
/** 비탈 끝을 단면 끝까지 못 찾음(못 닫힘). */
const open = new Uint8Array(n * 4);
let reachMax = 0;
rows.forEach((row, r) => {
const dx = row.left[0] - row.right[0];
@@ -379,22 +491,62 @@ export function buildDeformField(
previous = now;
}
// 못 찾으면 단면 끝까지만 — 마지막 구배로 60 m 까지 깎던 폭주 구덩이(115-5 P0-1 ①).
if (!found) reach = profile[(count - 1) * 2];
const end = profile[(count - 1) * 2];
if (!found) reach = end;
// 못 닫힘(115-7) — 단면 끝(측점 = B06 비탈 끝) 너머 튐 폭보다 멀리서야 닫힌 성토. 연장 구배를
// 안전 상한이 눌러 원지반 위 띠(지느러미)가 되던 행 — 이웃 좋은 행으로 보간한다.
const slack = Math.max(FILL_JUMP_M, end * FILL_JUMP_RATIO);
if (!found || (kind === "fill" && reach > end + slack)) open[slot] = 1;
}
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]);
}
});
});
});
// 성토 비탈 끝 종단 이음(115-7) — 짧아진 행 · 못 닫힘 행은 비탈 끝 밖을 덧댄다.
const chainages = rows.map((row) => row.chainage_m);
const station = new Uint8Array(n);
for (const section of build.sections ?? []) {
let r = 0;
while (r + 1 < n && chainages[r + 1] <= section.chainage_m) r += 1;
if (r + 1 < n && chainages[r + 1] - section.chainage_m < section.chainage_m - chainages[r]) {
r += 1;
}
if (Math.abs(chainages[r] - section.chainage_m) < 0.15) station[r] = 1;
}
const skirt = new Uint8Array(n * 4);
for (let si = 0; si < 2; si += 1) {
const pick = <T>(of: (slot: number) => T) => rows.map((_, r) => of(r * 4 + si * 2));
const raw = pick((slot) => catches[slot]);
const final = smoothFillCatches(
chainages,
raw,
pick((slot) => open[slot]),
station,
pick((slot) => (profiles[slot] ? 1 : 0)),
);
final.forEach((c, r) => {
const slot = r * 4 + si * 2;
catches[slot] = c;
if (c > 0 && (open[slot] || c < raw[r] - 0.01 || raw[r] <= 0)) skirt[slot] = 1;
});
}
for (let slot = 0; slot < n * 4; slot += 1) {
const reach = catches[slot];
reachMax = Math.max(reachMax, reach + (skirt[slot] ? FILL_SKIRT_M : 0));
const profile = profiles[slot];
if (!profile) continue;
const count = profile.length / 2;
// 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만.
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]);
}
}
// 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다.
let maxHalf = 0;
hw.forEach((value) => (maxHalf = Math.max(maxHalf, value)));
@@ -482,10 +634,15 @@ export function buildDeformField(
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);
const reach = catches[slot];
if (!profile || reach <= 0) return;
// 덧댐(115-7) — 이은 비탈 끝 밖 2 m 는 비탈 끝 높이에서 원지반으로 내려 수직 면을 없앤다.
if (s >= reach && (!skirt[slot] || s >= reach + FILL_SKIRT_M)) return;
const pad = s < reach ? 0 : (s - reach) / FILL_SKIRT_M;
const rel = profileZ(profile, Math.min(s, reach), free);
if (rel === null) return;
let line = anchor[2] + rel;
line += (z - line) * pad;
// 안전 상한(115-5 P0-1 ④) — 단면 높이 + 1 m 넘게는 옮기지 않는다. 구조물에서 끝나는
// 단면(BOX)은 지반까지 안 내려가 단면 높이가 기준이 못 된다.
const cap = heights[slot] + SAFETY_M;
@@ -0,0 +1,210 @@
/* PLAN 115-7 단위검증 헬퍼 — 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;
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 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:1.5 · 4 m 아래 · 비탈 끝 9) · 우 절토(1:1 · 4 m 위 · 비탈 끝 −7). */
function station(chainage) {
const pts = [
[-7, 104],
[-HALF, ROAD_Z],
[-2.5, ROAD_Z],
[2.5, ROAD_Z],
[HALF, ROAD_Z],
[9, 96],
];
const samples = [];
for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: o > 0 ? 96 : 104 });
return {
station_id: `S${chainage}`,
chainage_m: chainage,
label: "",
kind: "regular",
center_x: chainage,
center_y: 0,
center_z: ROAD_Z,
frame: { left_xy: [0, 1] },
samples,
design: {
section_mode: "right_cut",
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 SECTIONS = [0, 20, 40, 60, 80, 100].map(station);
function fieldOf(groundAt) {
const build = buildCorridor(SECTIONS, [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
]);
build.sections = SECTIONS;
const field = deform.buildDeformField(build, deform.stationProfileSource(SECTIONS), groundAt);
return field;
}
/** 행 · 측 성토 비탈 끝 열(노선 길이 순). */
function catches(field, si, ki) {
return field.rows.map((row, r) => ({ ch: row.chainage_m, c: field.catchAt(r, si, ki) }));
}
/** 지느러미 행 — 성토 행끼리 ±3 m 중앙값 + 3 m 넘음. */
function fins(list) {
let count = 0;
for (const { ch, c } of list) {
if (c <= 0) continue;
const near = list
.filter((o) => o.c > 0 && Math.abs(o.ch - ch) <= 3)
.map((o) => o.c)
.sort((a, b) => a - b);
if (c > near[near.length >> 1] + 3) count += 1;
}
return count;
}
const wobble = (list) => {
let sum = 0;
for (let i = 1; i < list.length; i += 1) {
if (list[i].c > 0 && list[i - 1].c > 0) sum += Math.abs(list[i].c - list[i - 1].c);
}
return (sum / (list[list.length - 1].ch - list[0].ch)) * 100;
};
const results = {};
// ① 도로에 직각인 골짜기 — 좁은 골(49~51 m) · 넓은 골(64~72 m)에서 지반이 3 m 더 깊다. 옛 규칙은
// 골 행만 연장 구배로 10.5 m 까지 뻗어 지느러미 · 수직 벽. 새 규칙은 이웃 좋은 행(6 m)으로 보간 +
// 비탈 끝 밖 2 m 덧댐. 우측 절토는 같은 골을 그대로 따른다(절토는 안 바꿈).
{
const gully = (x) => (x > 49 && x < 51) || (x > 64 && x < 72);
const groundAt = (x, y) => {
if (y >= 0) return gully(x) && y > 5 ? 93 : 96;
return gully(x) && y < -5 ? 101 : 104;
};
const field = fieldOf(groundAt);
const fill = catches(field, 0, 0);
const inGully = fill.filter((o) => gully(o.ch));
const cutGully = catches(field, 1, 1).filter((o) => gully(o.ch) && o.ch > 50 && o.ch < 50.5);
const zAt = (x, s) => field.deformZ(x, HALF + s, groundAt(x, HALF + s));
results.valley = {
fins: fins(fill),
gullyMax: r4(Math.max(...inGully.map((o) => o.c))),
gullyMin: r4(Math.min(...inGully.map((o) => o.c))),
skirtMid: r4(zAt(68, 7)), // 비탈 끝(6) 밖 1 m — 96 에서 93 으로 반쯤
skirtOut: r4(zAt(68, 8.5)), // 덧댐 밖 — 원지반 그대로
outside: r4(zAt(30, 8)), // 골 밖 비탈 끝 밖 — 원지반
cutGully: r4(Math.max(...cutGully.map((o) => o.c))),
cutFlat: r4(
field.catchAt(
field.rows.findIndex((row) => row.chainage_m >= 30),
1,
1,
),
),
};
}
// ② 계단 지형 — 1 m 띠마다 지반이 0.6 m 오르내린다(비탈 끝 6 ↔ 5.1). 원뿔 넓히기로 출렁임이 준다.
{
const step = (x) => (Math.floor(x) % 2 ? 0.6 : 0);
const groundAt = (x, y) => (y >= 0 ? 96 + step(x) : 104);
const field = fieldOf(groundAt);
const fill = catches(field, 0, 0).filter((o) => o.ch > 1 && o.ch < 99);
// 날것 출렁임 — 띠 경계마다 0.9 m(6 − 5.1) 씩.
const raw = fill.map((o) => ({ ch: o.ch, c: step(o.ch) ? 5.1 : 6 }));
results.steps = { wobble: r4(wobble(fill)), rawWobble: r4(wobble(raw)), fins: fins(fill) };
}
// ③ ④ ⑤ 열 단위 — 못 닫힘 보간 · 측점 행 날것 · 0 행 끊음 / 짧은 틈 이음.
{
const n = 101;
const ch = Array.from({ length: n }, (_, i) => i * 0.2);
const raw = new Array(n).fill(6);
const open = new Array(n).fill(0);
const station = new Array(n).fill(0);
const shaped = new Array(n).fill(1);
for (let i = 20; i <= 24; i += 1) {
raw[i] = 15;
open[i] = 1;
}
station[50] = 1;
raw[50] = 9; // 측점 행 — 튀어도 날것
station[60] = 1;
raw[60] = 20;
open[60] = 1; // 못 닫힘 측점 — 보간
for (let i = 70; i <= 72; i += 1) raw[i] = 0; // 짧은 틈(0.4 m) — 단면 있음 → 잇는다
for (let i = 80; i <= 82; i += 1) {
raw[i] = 0;
shaped[i] = 0; // 단면 없음 → 끊음
}
const out = deform.smoothFillCatches(ch, raw, open, station, shaped);
const gap = [];
for (let i = 0; i < n; i += 1) if (raw[i] === 0 && shaped[i] === 0) gap.push(out[i]);
results.column = {
openMax: r4(Math.max(...out.slice(20, 25))),
stationKept: r4(out[50]),
stationOpen: r4(out[60]),
bridged: r4(Math.min(...out.slice(70, 73))),
cutGap: r4(Math.max(...gap)),
neighbourStep: r4(
Math.max(
...out
.slice(1)
.map((c, i) => (out[i] > 0 && c > 0 ? Math.abs(c - out[i]) / 0.2 : 0))
.filter((_, i) => i < 49 || i > 51),
),
),
};
}
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""PLAN 115-7 — B05 3D 성토 비탈 끝 종단 이음(공통 변형 장) 합성 검사.
Node 도우미(helper_115_7_fill_continuity.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
결과(JSON)를 단언한다. 직각 골짜기 지느러미 0 · 덧댐 · 계단 지형 출렁임 · 못 닫힘 보간 ·
측점 행 날것 · 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_7_fill_continuity.cjs")],
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_valley_has_no_fins(results):
r = results["valley"]
assert r["fins"] == 0
assert (
r["gullyMax"] == 6 and r["gullyMin"] == 6
) # 골 행도 이웃 비탈 끝(6 m) — 10.5 m 로 안 뻗음
def test_valley_skirt_replaces_wall(results):
r = results["valley"]
assert r["skirtMid"] == 94.5 # 비탈 끝 밖 1 m — 96 → 93 반쯤
assert r["skirtOut"] == 93 # 덧댐 밖 — 원지반
assert r["outside"] == 96 # 골 밖 — 원지반 그대로
def test_cut_unchanged(results):
r = results["valley"]
assert r["cutFlat"] == 4
assert 2 < r["cutGully"] < 2.3 # 절토는 골을 날것 그대로 따른다
def test_step_terrain_wobble_small(results):
r = results["steps"]
assert r["fins"] == 0
assert r["wobble"] < 0.7 * r["rawWobble"]
def test_open_rows_interpolated(results):
r = results["column"]
assert r["openMax"] == 6 # 못 닫힘(15) → 이웃 6
assert r["stationOpen"] < 9 # 못 닫힘 측점도 보간(날것 20 아님)
def test_station_row_kept_and_zero_breaks(results):
r = results["column"]
assert r["stationKept"] == 9 # 측점 행 날것
assert r["bridged"] == 6 # 단면 있는 짧은 틈 — 잇는다
assert r["cutGap"] == 0 # 단면 없는 행 — 0 그대로(구간 끊음)
assert r["neighbourStep"] <= 1 + 1e-6 # 이웃 행 차 1 m/m 안