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:
2026-10-08 20:52:32 +09:00
co-authored by Claude Opus 5.5
parent 2ecbd9f49b
commit bf1106aa8e
9 changed files with 375 additions and 26 deletions
+2
View File
@@ -68,6 +68,8 @@ export const STATE_REGISTRY = {
"m01-logic-panel": { bucket: "pref", scope: "global" },
/** B05 3D 예상형상 그리기 — "1" 새 방식(지형 변형 · 기본) · "0" 옛 방식(115-1 비교용) */
"corridor-deform": { bucket: "pref", scope: "global" },
/** B05 3D 옛 방식 A(실루엣 갈아 끼우기 · 115-4 비교용) — "1" 켬 · 그 밖 끔 */
"corridor-legacy": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
+11 -4
View File
@@ -29,6 +29,7 @@ import {
serialize,
type CorridorEnvelope,
} from "./B05_Profile_UI_Corridor_Envelope";
import { rememberLegacyInputs } from "./B05_Profile_UI_Corridor_Legacy";
/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */
export interface CorridorStructureHook {
@@ -236,12 +237,18 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P
await writeCachedBytes(projectId, url, bytes);
}
/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다).
* 옛 방식 A(115-4) 입력도 함께 묶는다 — 단추를 켤 때 즉석 빌드용(메모리만). */
function withSections(
build: CorridorBuildResult | null,
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): CorridorBuildResult | null {
if (build) build.sections = detail.cross_sections;
if (build) {
build.sections = detail.cross_sections;
rememberLegacyInputs(build, { detail, routePoints, designSamples });
}
return build;
}
@@ -258,7 +265,7 @@ export function refreshCorridor(
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(withSections(build, detail)))
.then((build) => viewer.setCorridor(withSections(build, detail, routePoints, designSamples)))
.catch(() => viewer.setCorridor(null));
}
@@ -278,7 +285,7 @@ export function loadCorridorIfFresh(
if (!routeId) return Promise.resolve(false);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false)
.then((build) => {
if (build) viewer.setCorridor(withSections(build, detail));
if (build) viewer.setCorridor(withSections(build, detail, routePoints, designSamples));
return build !== null;
})
.catch(() => false);
+10 -2
View File
@@ -177,9 +177,15 @@ export function buildCorridor(
ground_elevation_m?: number;
}>,
subdivideStepM: number = SUBDIVIDE_STEP_M,
/**
* 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 배수관 세트 측 비탈을
* 실루엣으로 갈아 끼우고 스케치 · 자르기 · 패치 없이 리본 · 외곽 · 마구리 · 솔리드만 낸다.
*/
options: { legacy?: boolean } = {},
): CorridorBuildResult | null {
const legacy = options.legacy === true;
const stations = crossSections
.map(classifyStation)
.map((section) => classifyStation(section, { silhouette: legacy }))
.filter((s): s is StationPieces => s !== null)
.sort((a, b) => a.chainage_m - b.chainage_m);
if (stations.length < 2) return null;
@@ -272,7 +278,8 @@ export function buildCorridor(
};
const structures = buildCorridorStructures(crossSections, frameAt);
// 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다.
const spanBounds = structureSpanBounds(crossSections);
// 옛 방식 A 는 그날처럼 등간격 행만 쓴다.
const spanBounds = legacy ? [] : structureSpanBounds(crossSections);
for (let i = 0; i < stations.length - 1; i += 1) {
const s0 = stations[i];
@@ -590,6 +597,7 @@ export function buildCorridor(
});
if (points.length >= 2) caps.push({ points });
});
if (legacy) return { ribbons, outline, caps, structures };
// 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌
// 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다.
// 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다.
@@ -0,0 +1,60 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Legacy.ts
* 3D 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 비교용 빌드.
*
* 저장하지 않는다(저장본 · BUILD_VERSION · 해시 · 서버 사전생성 무관). [옛 방식 A] 단추를
* 처음 켤 때 브라우저가 즉석으로 만들고 해시별로 메모한다. 입력은 본체 `refreshCorridor`
* 가 코리도를 뷰어에 넘길 때 그 빌드에 묶어 둔다.
* ========================================================================== */
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { corridorHash } from "./B05_Profile_UI_Corridor_Envelope";
interface LegacyInputs {
detail: SectionDetailResponse;
routePoints: RoutePoint[];
designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>;
hash?: string;
}
const inputsOf = new WeakMap<CorridorBuildResult, LegacyInputs>();
let memo: { hash: string; build: CorridorBuildResult | null } | null = null;
/** 본체 코리도 빌드에 옛 방식 A 입력을 묶어 둔다(메모리만). */
export function rememberLegacyInputs(build: CorridorBuildResult, inputs: LegacyInputs): void {
inputsOf.set(build, inputs);
}
function hashOf(inputs: LegacyInputs): string {
inputs.hash ??= corridorHash(inputs.detail, inputs.routePoints);
return inputs.hash;
}
/** 이 빌드의 옛 방식 A 가 이미 만들어져 있는가(입력이 없으면 만들 것도 없다). */
export function legacyReady(build: CorridorBuildResult): boolean {
const inputs = inputsOf.get(build);
return !inputs || memo?.hash === hashOf(inputs);
}
/** 옛 방식 A 빌드 — 해시가 같으면 메모를 쓴다. 입력이 없거나 빌드 실패면 null. */
export function legacyCorridorOf(build: CorridorBuildResult): CorridorBuildResult | null {
const inputs = inputsOf.get(build);
if (!inputs) return null;
const hash = hashOf(inputs);
if (memo?.hash !== hash) {
const { detail, routePoints, designSamples } = inputs;
memo = {
hash,
build: buildCorridor(
detail.cross_sections,
routePoints,
designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples,
undefined,
{ legacy: true },
),
};
}
return memo.build;
}
+20 -14
View File
@@ -88,17 +88,17 @@ export interface StationPieces {
}
/**
* 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 로직의 on/off
* (2026-08-25 사용자 지시: 기존 성토면을 강제 변형시키던 로직 비활성화).
* 측점 분류 선택(115-4).
*
* 껐을 때의 동작: 성토·절토 비탈은 측점 설계선 그대로 지반까지 간다. 구조물과 겹치는
* 자리는 나중에 **탑뷰 투영면적으로 도려낸다**(`_Corridor_Region.ts`의 StructureFootprint
* → `_Corridor_Clip.ts`). 서피스를 비틀지 않고 잘라내는 쪽이 형상이 깨끗하다.
*
* 켜면 종전 동작(노폭 연장 + 구조물 실루엣 + 구간 내 지반 트림 금지)으로 돌아간다 —
* 되돌릴 일이 있어 코드는 남겨 둔다.
* `silhouette` — 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 옛 방식 A
* (08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」). 기본 끔(2026-08-25 사용자 지시: 기존 성토면을
* 강제 변형시키던 로직 비활성화) — 끄면 비탈은 측점 설계선 그대로 지반까지 가고 구조물과
* 겹치는 자리는 탑뷰 투영으로 도려낸다. 켜면 그날 그대로 **배수관 세트만** 갈아 끼우고
* (BOX · 세월교는 원본 비탈) 노견 확장 상시 적용(08-25 ①, 그날 뒤)은 하지 않는다.
*/
const STRUCTURE_SILHOUETTE_OVERRIDE: boolean = false;
export interface ClassifyOptions {
silhouette?: boolean;
}
export function pieceKey(kind: CorridorKind, side: CorridorSide): string {
return `${kind}:${side}`;
@@ -234,7 +234,11 @@ function relativeToRoadEdge(
}
/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */
export function classifyStation(section: CrossSection): StationPieces | null {
export function classifyStation(
section: CrossSection,
/** `.map(classifyStation)` 이 넘기는 인덱스(number)는 끔으로 읽는다. */
options?: ClassifyOptions | number,
): StationPieces | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
@@ -394,11 +398,13 @@ export function classifyStation(section: CrossSection): StationPieces | null {
};
// ── 구조물 측점(배수관 세트·BOX암거·세월교) — 그 측 비탈을 B06 횡단 실루엣으로
// 갈아 끼우고 종방향 점유 구간을 등록한다. 지금은 꺼져 있다
// (STRUCTURE_SILHOUETTE_OVERRIDE) — 성토면은 그대로 두고 투영커브로 도려낸다.
// 갈아 끼우고 종방향 점유 구간을 등록한다. 기본은 꺼져 있다(options.silhouette ·
// 옛 방식 A 단추만 켬) — 성토면은 그대로 두고 투영커브로 도려낸다.
const silhouettes = structureSilhouettes(section);
for (const silhouette of STRUCTURE_SILHOUETTE_OVERRIDE ? silhouettes : []) {
const legacy = typeof options === "object" && options.silhouette === true;
const drainageOnly = section.box || section.ford ? [] : silhouettes;
for (const silhouette of legacy ? drainageOnly : []) {
const side = silhouette.side;
const kind: CorridorKind =
silhouette.kind === "auto" ? (side === "left" ? leftRole : rightRole) : silhouette.kind;
@@ -409,7 +415,7 @@ export function classifyStation(section: CrossSection): StationPieces | null {
// 노견 조각을 넓힌다. 원 노견 끝을 꼭짓점으로 남긴다(무릎 — 물매 유지). 넓힌
// 노견은 shoulderSpans 제어점으로 구조물 구간 내내 유지된다.
let shoulderSpans: StationPieces["shoulderSpans"];
for (const silhouette of silhouettes) {
for (const silhouette of legacy ? [] : silhouettes) {
const widen = silhouette.widenTo;
const side = silhouette.side;
if (!widen) continue;
+8 -1
View File
@@ -5,7 +5,12 @@
* ========================================================================== */
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
import { corridorDeformOn, setCorridorDeform } from "./B05_Profile_UI_Viewer_Corridor";
import {
corridorDeformOn,
corridorLegacyOn,
setCorridorDeform,
setCorridorLegacy,
} from "./B05_Profile_UI_Viewer_Corridor";
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
export interface ViewControlCallbacks {
@@ -65,6 +70,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
toggleButton("예상형상", true, callbacks.onCorridorVisible),
// 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치).
toggleButton("새 방식", corridorDeformOn(), setCorridorDeform),
// 옛 방식 A(115-4) — 08-25 실루엣 갈아 끼우기 · 켜면 새 방식 단추와 상관없이 A 형상.
toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy),
toggleButton("등고선", true, callbacks.onContoursVisible),
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
+49 -5
View File
@@ -21,6 +21,8 @@ import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy";
import { showToast } from "@ui/ui_template_elements";
/**
* 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치).
@@ -37,6 +39,21 @@ export function setCorridorDeform(on: boolean): void {
deformListeners.forEach((listener) => listener(on));
}
/**
* 옛 방식 A(115-4 · 08-25 실루엣 갈아 끼우기) — 켜면 「새 방식」 단추와 상관없이 A 형상.
* 끄면 지금처럼(새 방식 켬/끔). 비교용 — 저장하지 않는다.
*/
const legacyListeners = new Set<(on: boolean) => void>();
export function corridorLegacyOn(): boolean {
return readStateRaw("corridor-legacy") === "1";
}
export function setCorridorLegacy(on: boolean): void {
writeStateRaw("corridor-legacy", on ? "1" : "0");
legacyListeners.forEach((listener) => listener(on));
}
/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */
function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
return {
@@ -137,6 +154,14 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
if (corridorBuild) set(corridorBuild);
};
deformListeners.add(onDeform);
let legacyOn = corridorLegacyOn();
/** 지금 그리고 있는 옛 방식 A 빌드 — 본체 빌드와 따로 둔 객체(심 보정이 제자리 수정). */
let legacyBuild: CorridorBuildResult | null = null;
const onLegacy = (on: boolean): void => {
legacyOn = on;
if (corridorBuild) set(corridorBuild);
};
legacyListeners.add(onLegacy);
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
function applyCorridorVisibility(): void {
@@ -282,13 +307,14 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
const bounds = deps.bounds();
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const split = ensureBandSplit(buildAtSchedule);
const split = ensureBandSplit(legacyBuild ?? buildAtSchedule);
if (!split) return;
const index = deformOn ? deps.heightIndex() : null;
// 옛 방식 A 는 지형을 비탈 끝 띠만 자른다(스케치 · 자른 자리 없음 → 원지반 되살림 없음).
const index = deformOn && !legacyBuild ? deps.heightIndex() : null;
const deformed = index ? deformTerrain(split, buildAtSchedule, bounds, index) : null;
(window as unknown as { __corridorDeform?: unknown }).__corridorDeform =
deformed?.stats ?? null;
const clipped = deformed?.root ?? clipTerrain(split, buildAtSchedule, bounds);
const clipped = deformed?.root ?? clipTerrain(split, legacyBuild ?? buildAtSchedule, bounds);
disposeClippedTerrain();
clippedTerrain = clipped;
scene.add(clipped);
@@ -306,11 +332,28 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
}
function set(build: CorridorBuildResult | null): void {
// 옛 방식 A 는 처음 켤 때 즉석으로 만든다(몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다.
if (build && legacyOn && !legacyReady(build)) {
corridorBuild = build;
showToast("옛 방식 A(실루엣) 형상을 만드는 중입니다…", "info");
window.setTimeout(() => {
if (corridorBuild !== build || !legacyOn) return;
legacyCorridorOf(build);
set(build);
}, 50);
return;
}
disposeCorridorGroup();
corridorBuild = build;
setCorridorBuildSummary(build);
legacyBuild = build && legacyOn ? legacyCorridorOf(build) : null;
setCorridorBuildSummary(legacyBuild ?? build);
const bounds = deps.bounds();
if (build && bounds) {
if (legacyBuild && bounds) {
snapCorridorEdges(legacyBuild);
corridorGroup = createCorridorGroup(legacyBuild, bounds);
scene.add(corridorGroup);
deps.onGroup();
} else if (build && bounds) {
// 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다.
if (!deformOn) {
snapCorridorEdges(build);
@@ -362,6 +405,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
clipped: () => clippedTerrain,
dispose() {
deformListeners.delete(onDeform);
legacyListeners.delete(onLegacy);
corridorBuild = null; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
@@ -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