diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts index 0a8311995..b550f7d34 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts @@ -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 = new Array(n * 4).fill(null); const catches = new Float64Array(n * 4); + const heights = new Float64Array(n * 4); + const bends: Array> = 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]; + }, + }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts index 65b042aae..696437682 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts @@ -117,13 +117,20 @@ export function structureSlopes(sections: ReadonlyArray): 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({ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts index feb3aef93..960302b92 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts index 6b18e027d..35a6ab924 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts @@ -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 }; }; } diff --git a/resources/tester/helper_115_5_corridor_improve.cjs b/resources/tester/helper_115_5_corridor_improve.cjs new file mode 100644 index 000000000..38f674344 --- /dev/null +++ b/resources/tester/helper_115_5_corridor_improve.cjs @@ -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)); diff --git a/resources/tester/test_115_5_corridor_improve.py b/resources/tester/test_115_5_corridor_improve.py new file mode 100644 index 000000000..5ffdc4716 --- /dev/null +++ b/resources/tester/test_115_5_corridor_improve.py @@ -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