Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-08 20:21:35 +09:00
11 changed files with 1478 additions and 6 deletions
@@ -0,0 +1,424 @@
/* PLAN 115 단위검증 헬퍼 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const THREE = require(path.join(ROOT, "config", "node_modules", "three"));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request === "three") return THREE;
// 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다.
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
return new Proxy({}, { get: () => () => undefined });
}
const target = request.startsWith("@util/")
? path.join(ROOT, "common_util", request.slice(6))
: path.join(path.dirname(full), request);
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform"));
const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain"));
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000);
const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5)
const ROAD_Z = 100;
/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */
function station(chainage, mode, groundZ, ratio) {
const w = Math.abs(groundZ - ROAD_Z) * ratio;
const pts = [
[-30, groundZ],
[-(HALF + w), groundZ],
[-HALF, ROAD_Z],
[-2.5, ROAD_Z],
[2.5, ROAD_Z],
[HALF, ROAD_Z],
[HALF + w, groundZ],
[30, groundZ],
];
const samples = [];
for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
return {
station_id: `S${chainage}`,
chainage_m: chainage,
label: "",
kind: "regular",
center_x: chainage,
center_y: 0,
center_z: groundZ,
frame: { left_xy: [0, 1] },
samples,
design: {
section_mode: mode,
road_edges: {
left: { offset_m: HALF, elevation_m: ROAD_Z },
right: { offset_m: -HALF, elevation_m: ROAD_Z },
},
carriageway_edges: {
left: { offset_m: 2.5, elevation_m: ROAD_Z },
right: { offset_m: -2.5, elevation_m: ROAD_Z },
},
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
ditch: { type: "none" },
ditch_enabled: false,
ditch_side: "left",
},
};
}
const ROUTE = [
{ x: 0, y: 0 },
{ x: 40, y: 0 },
];
function fieldOf(sections, groundAt) {
const build = buildCorridor(sections, ROUTE);
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt);
return { build, field };
}
const results = {};
// ① 성토 — 지반 96 · 1:1.5. 비탈 안 정점은 성토선으로 올라가고, 끝(6 m) 밖은 그대로.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const { build, field } = fieldOf(sections, () => 96);
results.fill = {
rows: field.rows.length,
anchorL: field.rows[10].left.map(r4),
left2: r4(field.deformZ(10, HALF + 2, 96)),
right2: r4(field.deformZ(10, -HALF - 2, 96)),
boundary: r4(field.deformZ(10, HALF, 96)),
beyond: r4(field.deformZ(10, HALF + 9, 96)),
// 지반이 성토선보다 높은 정점은 그대로(clamp 아래쪽만 올린다).
above: r4(field.deformZ(10, HALF + 2, 99.5)),
oldRibbons: build.ribbons.length,
};
}
// ② 첫 지반 교차점 밖은 원지반 — 비탈 끝(6 m) 너머 골짜기(80)는 연장선(92)으로 메우지 않는다.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const groundAt = (x, y) => (Math.abs(y) - HALF > 10 ? 80 : 96);
const { field } = fieldOf(sections, groundAt);
results.valley = {
inside: r4(field.deformZ(10, HALF + 2, 96)),
farValley: r4(field.deformZ(10, HALF + 15, 80)),
};
}
// ③ 절토 — 지반 104 · 1:1. 비탈 안 정점은 절토선으로 내려가고 끝(4 m) 밖은 그대로.
{
const sections = [0, 20, 40].map((c) => station(c, "both_cut", 104, 1.0));
const { field } = fieldOf(sections, () => 104);
results.cut = {
left2: r4(field.deformZ(10, HALF + 2, 104)),
beyond: r4(field.deformZ(10, HALF + 6, 104)),
below: r4(field.deformZ(10, HALF + 2, 101)),
};
}
// ④ 측점 사이 보간 — 0 측점 1:1.5, 40 측점 1:2. 가운데(20)는 앵커 기준 상대 단면 보간.
{
const sections = [station(0, "both_fill", 96, 1.5), station(40, "both_fill", 96, 2.0)];
const { field } = fieldOf(sections, () => 96);
results.taper = {
at0: r4(field.deformZ(0.1, HALF + 2, 96)),
at20: r4(field.deformZ(20, HALF + 2, 96)),
at40: r4(field.deformZ(39.9, HALF + 2, 96)),
};
}
// ⑤ 단면 밖 마지막 구배 연장 · 반대 측 빌리기 — 단면 함수 단독.
{
const profile = Float64Array.from([0, 0, 2, -1, 4, -2]);
results.profile = {
mid: r4(deform.profileZ(profile, 1)),
extend: r4(deform.profileZ(profile, 10)),
noExtend: deform.profileZ(profile, 10, true),
};
}
// ⑥ Three 지형 — 사본에만 · XY 고정 · 노면부 구멍 · 색 · 흑백 보존 표식.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
build.sections = sections;
const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] };
const cx = 20;
const cy = 0;
const cz = 100;
const step = 0.5;
const nx = Math.round(60 / step) + 1;
const ny = Math.round(40 / step) + 1;
const positions = new Float32Array(nx * ny * 3);
const colors = new Uint8Array(nx * ny * 4).fill(200);
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) {
const v = j * nx + i;
positions[v * 3] = -10 + i * step - cx;
positions[v * 3 + 1] = 96 - cz;
positions[v * 3 + 2] = -(-20 + j * step - cy);
}
}
const index = [];
for (let j = 0; j < ny - 1; j += 1) {
for (let i = 0; i < nx - 1; i += 1) {
const a = j * nx + i;
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true));
geometry.setIndex(index);
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
const before = positions.slice();
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
const heightIndex = new TerrainHeightIndex(terrain);
const out = deformTerrain(split, build, bounds, heightIndex);
let xyMoved = 0;
let inRoad = 0;
let probe = null;
let keepCount = 0;
let tinted = 0;
out.root.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
const p = child.geometry.getAttribute("position");
const c = child.geometry.getAttribute("color");
for (let v = 0; v < nx * ny; v += 1) {
if (p.getX(v) !== before[v * 3] || p.getZ(v) !== before[v * 3 + 2]) xyMoved += 1;
const mx = p.getX(v) + cx;
const my = -p.getZ(v) + cy;
if (Math.abs(mx - 10) < 1e-6 && Math.abs(my - (HALF + 2)) < 1e-6) probe = p.getY(v) + cz;
if (child.userData.keepColor[v]) {
keepCount += 1;
if (c.getX(v) !== 200 / 255) tinted += 1;
}
}
const idx = child.geometry.getIndex();
for (let t = 0; t < idx.count; t += 3) {
let sx = 0;
let sy = 0;
for (let k = 0; k < 3; k += 1) {
sx += p.getX(idx.getX(t + k)) + cx;
sy += -p.getZ(idx.getX(t + k)) + cy;
}
sx /= 3;
sy /= 3;
if (sx > 0.5 && sx < 39.5 && Math.abs(sy) < HALF - 0.05) inRoad += 1;
}
});
results.three = {
originalUntouched: before.every((value, i) => value === positions[i]),
xyMoved,
inRoad,
probe: r4(probe),
moved: out.stats.moved,
raised: out.stats.raised,
keepCount,
tinted,
};
}
// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다.
{
const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const env = upperEnvelope(
[
[0, 0],
[2, 0],
[2.1, -3],
[1.8, -3.2],
[5, -4],
].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
);
const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m;
results.envelope = {
monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m),
at18: r4(at(1.8)),
at21: r4(at(2.1)),
at5: r4(at(5)),
};
}
/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */
function slopeOf(points, opts = {}) {
const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
return {
chainage_m: 20,
side: "left",
kind: "fill",
span: { beforeM: 2, afterM: 2 },
points: resample(
points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
48,
),
endsAtStructure: false,
...opts,
};
}
// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5).
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const shelf = slopeOf([
[0, 0],
[4, 0],
[6, -4],
]);
const source = deform.stationProfileSource(sections, [shelf]);
const field = deform.buildDeformField(build, source, () => 96);
results.span = {
in20: r4(field.deformZ(20, HALF + 2, 96)),
in19: r4(field.deformZ(19, HALF + 2, 96)),
taper10: r4(field.deformZ(10, HALF + 2, 96)),
taper30: r4(field.deformZ(30, HALF + 2, 96)),
right20: r4(field.deformZ(20, -HALF - 2, 96)),
};
}
// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const box = slopeOf(
[
[0, 0],
[3, -1],
],
{ endsAtStructure: true },
);
const field = deform.buildDeformField(
build,
deform.stationProfileSource(sections, [box]),
() => 96,
);
results.box = {
inside: r4(field.deformZ(20, HALF + 2, 96)),
outside: r4(field.deformZ(20, HALF + 5, 96)),
normal: r4(field.deformZ(5, HALF + 5, 96)),
};
}
// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const source = deform.stationProfileSource(sections);
const loop = [
[8, 4],
[12, 4],
[12, 8],
[8, 8],
];
const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]);
const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]);
const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 });
const wings = [
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] },
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] },
];
const limits = noFillLimits(sections, wings);
results.noFill = {
blocked: r4(none.deformZ(10, HALF + 2, 96)),
ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)),
outsideLoop: r4(none.deformZ(20, HALF + 2, 96)),
limits: limits.length,
hullArea: limits.length ? limits[0].loop.length : 0,
};
}
// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
build.sections = sections;
const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 });
build.structures = [
{
chainage_m: 20,
kind: "revet",
polygon: [
[5, 95],
[6, 95],
[6, 100],
[5, 100],
],
rings: [ring(19), ring(20), ring(21)],
},
];
const step = 0.5;
const cx = 20;
const cz = 100;
const positions = [];
const nx = 121;
const ny = 81;
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step));
}
const index = [];
for (let j = 0; j < ny - 1; j += 1) {
for (let i = 0; i < nx - 1; i += 1) {
const a = j * nx + i;
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(index);
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial());
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
const out = deformTerrain(
split,
build,
{ x: [-10, 50], y: [-20, 20], z: [90, 110] },
new TerrainHeightIndex(terrain),
);
let inHole = 0;
let nearHole = 0;
out.root.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
const p = child.geometry.getAttribute("position");
const idx = child.geometry.getIndex();
for (let t = 0; t < idx.count; t += 3) {
let sx = 0;
let sy = 0;
for (let k = 0; k < 3; k += 1) {
sx += p.getX(idx.getX(t + k)) + cx;
sy += -p.getZ(idx.getX(t + k));
}
sx /= 3;
sy /= 3;
if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1;
if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1;
}
});
results.hole = { holes: out.stats.holes, inHole, nearHole };
}
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""PLAN 115 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
Node 도우미(helper_115_corridor_deform.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
결과(JSON)를 단언한다. 정점 규칙 `z' = clamp(지형, 성토선, 절토선)` · 비탈 끝 밖 원지반 ·
측점 사이 보간 · 단면 밖 마지막 구배 연장 · 사본에만 · XY 고정 · 노면부 구멍 · 색.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
FILL_2M = round(100 - 2 / 1.5, 4)
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_115_corridor_deform.cjs")],
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_fill_clamp_raises_to_fill_line(results):
r = results["fill"]
assert r["rows"] >= 2
assert r["anchorL"][1:] == [3, 100]
assert r["left2"] == FILL_2M
assert r["right2"] == FILL_2M
assert r["boundary"] == 100 # 노면부 끝 경계 정점 = 앵커 z
assert r["beyond"] == 96 # 비탈 끝(6 m) 밖은 원지반
assert r["above"] == 99.5 # 지반이 성토선 위면 그대로
def test_fill_stops_at_first_catch(results):
r = results["valley"]
assert r["inside"] == FILL_2M
assert r["farValley"] == 80 # 연장선(92)으로 골짜기를 메우지 않는다
def test_cut_clamp_lowers_to_cut_line(results):
r = results["cut"]
assert r["left2"] == 102
assert r["beyond"] == 104
assert r["below"] == 101 # 지반이 절토선 아래면 그대로
def test_station_interpolation(results):
r = results["taper"]
assert abs(r["at0"] - FILL_2M) < 0.02
assert abs(r["at40"] - 99.0) < 0.02
assert abs(r["at20"] - round(100 - 2 * 4 / 7, 4)) < 1e-3 # 상대 단면 보간
def test_profile_extends_last_grade(results):
r = results["profile"]
assert r["mid"] == -0.5
assert r["extend"] == -5
assert r["noExtend"] is None
def test_three_copy_only_xy_fixed_hole_and_tint(results):
r = results["three"]
assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로([예상형상] OFF = 원본)
assert r["xyMoved"] == 0
assert r["inRoad"] == 0 # 노면부 안 삼각형 없음
assert r["probe"] == FILL_2M
assert r["moved"] > 0 and r["raised"] == r["moved"]
assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]
def test_upper_envelope_keeps_top_of_wall_steps(results):
r = results["envelope"]
assert r["monotone"] is True
assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0)
assert r["at21"] == -3
assert r["at5"] == -4
def test_structure_span_fixed_and_tapers_to_neighbours(results):
r = results["span"]
assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정
assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼
assert r["taper30"] == r["taper10"]
assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로
def test_box_outside_body_stays_original_ground(results):
r = results["box"]
assert r["inside"] == round(100 - 2 / 3, 4)
assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함)
assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형
def test_no_fill_ring_inside_wings(results):
r = results["noFill"]
assert r["blocked"] == 96
assert r["ceiling"] == 97
assert r["outsideLoop"] == FILL_2M
assert r["limits"] == 1 and r["hullArea"] == 4
def test_structure_footprint_hole(results):
r = results["hole"]
assert r["holes"] == 1
assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음
assert r["nearHole"] > 0 # 바로 바깥은 남는다