feat(B05): 3D 옛 방식 A(08-25 실루엣 갈아 끼우기) 별개 단추 — 배수관 세트만 실루엣 · 스케치 · 자르기 · 패치 없음 · 지형 띠만 자름 · 저장 안 함(브라우저 즉석 · 해시별 메모)(115-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/* PLAN 115-4 단위검증 헬퍼 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 빌드.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 입력 = 픽스처(배수관 세트 · BOX · 세월교 측점).
|
||||
* 결과는 표준출력에 JSON 한 벌. 짝 = `test_115_4_corridor_legacy.py`.
|
||||
*/
|
||||
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);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
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 base = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
const found = [base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
);
|
||||
return loadTs(found ?? `${base}.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 { classifyStation } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
|
||||
const fixture = (name) =>
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", `${name}.json`), "utf8"))
|
||||
.cross_sections;
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
|
||||
/** 양 끝을 잇는 직선에서 가장 먼 점의 거리(m) — 직선 비탈이면 0. */
|
||||
function bend(points) {
|
||||
const a = points[0];
|
||||
const b = points[points.length - 1];
|
||||
const dx = b.offset_m - a.offset_m;
|
||||
const dz = b.elevation_m - a.elevation_m;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
return Math.max(
|
||||
...points.map(
|
||||
(p) => Math.abs((p.offset_m - a.offset_m) * dz - (p.elevation_m - a.elevation_m) * dx) / len,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
// ① 배수관 세트 측점(85.052 · 우절토 · 유출 좌측 성토) — 픽스처에 평면 자리가 없어 직선 노선에 얹는다.
|
||||
{
|
||||
const sections = fixture("b07_cross_overlay_sections").map((s) => ({
|
||||
...s,
|
||||
station_id: `S${s.chainage_m}`,
|
||||
center_x: s.chainage_m,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 1] },
|
||||
}));
|
||||
const set = sections.find((s) => s.culvert);
|
||||
const plain = classifyStation(set);
|
||||
const legacy = classifyStation(set, { silhouette: true });
|
||||
const side = set.uphill_side === "right" ? "left" : "right"; // 유출측
|
||||
const key = `fill:${side}`;
|
||||
const p0 = plain.pieces.get(key);
|
||||
const p1 = legacy.pieces.get(key);
|
||||
results.set = {
|
||||
chainage: set.chainage_m,
|
||||
key,
|
||||
plainBend: r3(bend(p0)),
|
||||
legacyBend: r3(bend(p1)),
|
||||
plainRatio: r3(
|
||||
Math.abs(p0[p0.length - 1].offset_m - p0[0].offset_m) /
|
||||
Math.abs(p0[p0.length - 1].elevation_m - p0[0].elevation_m),
|
||||
),
|
||||
wallSpans: Object.keys(legacy.wallSpans ?? {}).sort(),
|
||||
plainWallSpans: plain.wallSpans ?? null,
|
||||
indexArg:
|
||||
JSON.stringify([...classifyStation(set, 3).pieces]) === JSON.stringify([...plain.pieces]),
|
||||
};
|
||||
const route = sections.map((s) => ({ x: s.center_x, y: 0 }));
|
||||
const base = buildCorridor(sections, route);
|
||||
const old = buildCorridor(sections, route, undefined, undefined, { legacy: true });
|
||||
const ribbonAt = (build) =>
|
||||
build.ribbons.find((r) => r.kind === "fill" && r.side === side && !r.patch);
|
||||
const rowAt = (ribbon, chainage) => {
|
||||
let best = 0;
|
||||
ribbon.chainages.forEach((c, i) => {
|
||||
if (Math.abs(c - chainage) < Math.abs(ribbon.chainages[best] - chainage)) best = i;
|
||||
});
|
||||
const pts = [];
|
||||
for (let c = 0; c < ribbon.colCount; c += 1) {
|
||||
const i = (best * ribbon.colCount + c) * 3;
|
||||
pts.push({ offset_m: ribbon.positions[i + 1], elevation_m: ribbon.positions[i + 2] });
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
results.build = {
|
||||
basePlanCurves: Array.isArray(base.planCurves),
|
||||
baseCutWalls: Array.isArray(base.cutWalls),
|
||||
keys: Object.keys(old).sort(),
|
||||
patches: old.ribbons.filter((r) => r.patch).length,
|
||||
structures: old.structures.length,
|
||||
rowBend: r3(bend(rowAt(ribbonAt(old), set.chainage_m))),
|
||||
baseRowBend: r3(bend(rowAt(ribbonAt(base), set.chainage_m))),
|
||||
rows: ribbonAt(old).chainages.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ② BOX(352.14) · 세월교(173.087) 측점 — 옛 방식 A 에서도 비탈은 원본(실루엣 없음).
|
||||
{
|
||||
const sections = fixture("b06_73_4_body_guard_sections");
|
||||
results.others = sections
|
||||
.filter((s) => s.box || s.ford)
|
||||
.map((s) => {
|
||||
const plain = classifyStation(s);
|
||||
const legacy = classifyStation(s, { silhouette: true });
|
||||
const slopes = (st) =>
|
||||
JSON.stringify(
|
||||
[...st.pieces].filter(([k]) => k.startsWith("cut:") || k.startsWith("fill:")),
|
||||
);
|
||||
return {
|
||||
chainage: s.chainage_m,
|
||||
kind: s.box ? "box" : "ford",
|
||||
sameSlopes: slopes(plain) === slopes(legacy),
|
||||
wallSpans: legacy.wallSpans ?? null,
|
||||
shoulderSpans: legacy.shoulderSpans ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 115-4 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 별개 단추.
|
||||
|
||||
Node 도우미(helper_115_4_corridor_legacy.cjs)가 TS 를 트랜스파일해 픽스처 측점으로 돌리고,
|
||||
여기서는 결과(JSON)를 단언한다. 배수관 세트 측 성토 리본이 직선이 아니라 실루엣을 따름 ·
|
||||
스케치 · 자르기 · 패치 없음 · BOX · 세월교는 원본 비탈 · 기본 빌드 그대로 · 설정 키 등록.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_115_4_corridor_legacy.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_drainage_set_fill_follows_silhouette(results):
|
||||
r = results["set"]
|
||||
assert r["plainBend"] < 0.05 # 기본 = 직선 성토 비탈(1:1.2 꼴)
|
||||
assert 1.1 < r["plainRatio"] < 1.4
|
||||
assert r["legacyBend"] > 0.5 # 옛 방식 A = 벽 상단 · 전면 · 성토부선 실루엣
|
||||
assert r["wallSpans"] == ["left", "right"]
|
||||
assert r["plainWallSpans"] is None
|
||||
assert r["indexArg"] is True # `.map(classifyStation)` 인덱스는 끔
|
||||
|
||||
|
||||
def test_legacy_build_has_no_sketch_cut_patch(results):
|
||||
r = results["build"]
|
||||
assert r["keys"] == ["caps", "outline", "ribbons", "structures"] # planCurves · cutWalls 없음
|
||||
assert r["patches"] == 0
|
||||
assert r["structures"] > 0 # 솔리드는 지금 것 그대로 얹음
|
||||
assert r["rowBend"] > 0.5 # 측점 행 성토 리본이 실루엣 따름
|
||||
assert r["baseRowBend"] < 0.05
|
||||
assert r["basePlanCurves"] is True and r["baseCutWalls"] is True # 기본 빌드 그대로
|
||||
|
||||
|
||||
def test_box_and_ford_keep_original_slopes(results):
|
||||
kinds = {item["kind"]: item for item in results["others"]}
|
||||
assert set(kinds) == {"box", "ford"}
|
||||
for item in kinds.values():
|
||||
assert item["sameSlopes"] is True
|
||||
assert item["wallSpans"] is None
|
||||
assert item["shoulderSpans"] is None # 노견 확장 상시 적용(08-25 ①)도 그날 전
|
||||
|
||||
|
||||
def test_legacy_state_key_registered():
|
||||
registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry
|
||||
assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry
|
||||
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8")
|
||||
assert 'toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy)' in panel
|
||||
assert 'toggleButton("새 방식", corridorDeformOn(), setCorridorDeform)' in panel
|
||||
Reference in New Issue
Block a user