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

This commit is contained in:
2026-10-18 18:19:36 +09:00
8 changed files with 349 additions and 12 deletions
+29 -11
View File
@@ -44,9 +44,13 @@ import {
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
import {
refreshCrossDesigns,
type CrossRefreshInput,
} from "../../B06_Section/B06_Section_Cross_Refresh";
import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist";
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -229,16 +233,17 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []);
return (chainageM) => bermSpecAt(spans, chainageM);
},
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
savedCross: async (detail, routeId, stations) => {
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) crossDesignsUpdated(shared, updated);
profile.redraw();
return detailCross(detail, stations);
},
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그
// 값을 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진(439)
savedCross: (detail, routeId, stations) =>
refreshSavedCross(
{ projectId, routeId, detail, edits: profile.pipelineSource().edits },
stations,
(updated) => {
if (updated.length) crossDesignsUpdated(shared, updated);
profile.redraw();
},
),
plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관)
structureAreas: (detail) => {
// 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞
@@ -262,6 +267,19 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
});
}
/**
* 저장 노선 cross 차례(387-4 · 439) — 카드 자료(상세 설계)는 **늘** 다시 셈 · 묶음에 돌려줄 값만 측점 대조
* (상세 측점 ≠ 묶음 측점이면 null = 묶음 엔진). 대조가 앞서면 1 mm 어긋남에 카드 단추가 안 먹었다(요구 266).
*/
export async function refreshSavedCross(
input: CrossRefreshInput,
stations: ReadonlyArray<PipelineStation>,
onUpdated: (updated: number[]) => void,
): Promise<CrossOutput | null> {
onUpdated(await refreshCrossDesigns(input));
return detailCross(input.detail, stations);
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
export function sharedStructures(shared: DesignPageShared): StructuresSection {
if (shared.structures) return shared.structures;
+4
View File
@@ -20,6 +20,7 @@ import { clearState, readState, stateKey, writeState } from "../A00_Common/b_pag
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { t as L } from "@ui/ui_template_locale";
import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import type {
CrossDesign,
CrossDesignRequest,
@@ -306,6 +307,8 @@ export async function previewCrossDesigns(
>;
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
cutSlopeRatios?: Record<string, number>;
/** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */
designChoices?: Record<string, CrossDesignChoice>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
@@ -319,6 +322,7 @@ export async function previewCrossDesigns(
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
berms: options?.berms ?? null,
cut_slope_ratios: options?.cutSlopeRatios ?? null,
design_choices: options?.designChoices ?? null,
}),
},
);
+9 -1
View File
@@ -183,7 +183,7 @@ export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: nu
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` savedCross · 387-4) — 화면은 `pipelineCross` 로 부탁.
* 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` refreshSavedCross · 387-4) — 화면은 `pipelineCross` 로 부탁.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
@@ -486,6 +486,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
const { projectId, routeId, detail, edits, shouldApply } = input;
const choices = crossDesignChoices(projectId, routeId);
const response = await previewCrossDesigns(
projectId,
routeId,
@@ -498,6 +499,13 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
cutSlopeRatios: readCutSlopeSession(projectId, routeId),
// 카드 단추 선택(지반 · 측구 · 복합경사)도 — 안 실으면 폴백에서만 단추가 안 먹음(439) · 열쇠 = 측점 자리
designChoices: Object.fromEntries(
detail.cross_sections.flatMap((section) => {
const choice = choices.get(rockKey(section.chainage_m));
return choice ? [[section.chainage_m.toFixed(3), choice]] : [];
}),
),
},
);
if (shouldApply && !shouldApply()) return [];
+13
View File
@@ -494,6 +494,18 @@ async def regenerate_route_sections(
)
def _with_design_choices(
designs: list[dict[str, Any]], choices: dict[str, dict[str, Any]] | None
) -> list[dict[str, Any]]:
"""카드 선택 세션값을 저장분 위에 얹은 행 목록(439) — 뒤 행이 같은 측점 앞 행을 덮음."""
stored = {round(float(r["chainage_m"]), 3): r.get("design") or {} for r in designs}
picked = {round(float(key), 3): choice for key, choice in (choices or {}).items()}
return designs + [
{"chainage_m": key, "design": {**stored.get(key, {}), **choice}}
for key, choice in picked.items()
]
@router.post(
"/{project_id}/sections/{route_id}/cross-design/preview",
response_model=CrossDesignPreviewResponse,
@@ -522,6 +534,7 @@ async def preview_cross_designs(
)
stored_path = await get_project_storage_relative_path(connection, project_id)
designs = await get_cross_section_designs(connection, route_id)
designs = _with_design_choices(designs, request.design_choices)
project_root = Path(resolve_stored_project_path(stored_path))
detail = await asyncio.to_thread(
_read_section_detail,
+2
View File
@@ -400,6 +400,8 @@ class CrossDesignPreviewRequest(BaseModel):
# 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을
# 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다.
cut_slope_ratios: dict[str, float] | None = None
# 측점별 카드 선택(chainage 키 → 지반 · 단면 · 측구 · 복합경사). 위와 같은 성격 — 저장분 위에 얹음(439).
design_choices: dict[str, dict[str, Any]] | None = None
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
+150
View File
@@ -0,0 +1,150 @@
/* PLAN 439 헬퍼 — 저장 노선 cross 차례(`refreshSavedCross`)가 카드 단추(복합경사 · 측구 · 지반)를 늘 다시 셈:
* 묶음 측점 = 상세 측점(돌려줌) · 다름 1 mm 넘게(null = 묶음 엔진 · 카드 자료는 고침) · 서버 폴백에 카드 선택 실음.
* node helper_266_card_toggle.mjs <묶음.mjs> <job.json>
* 짝 = `test_266_card_toggle_mismatch.py` · 표준출력 JSON 한 벌.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
globalThis.addEventListener = () => undefined;
globalThis.removeEventListener = () => undefined;
globalThis.screen = { width: 1920, height: 1080 };
globalThis.location = {
href: "http://localhost/",
pathname: "/",
search: "",
hash: "",
origin: "http://localhost",
};
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
// 서버 폴백 창구만 받아 적음 — 그 밖 요청은 404(격자 없음 = 넓힘 안 함)
const previews = [];
globalThis.fetch = async (url, init) => {
if (String(url).includes("/cross-design/preview")) {
previews.push(JSON.parse(init.body));
return Response.json({ designs: [] });
}
return new Response("{}", { status: 404 });
};
const m = await import(pathToFileURL(process.argv[2]).href);
const R = 7;
const EDITS = { station_offsets: {}, curve_radii: {} };
const alignment = {
stations: job.sections.map((s) => ({ chainage_m: s.chainage_m, plan_elevation_m: s.plan_z })),
samples: job.sections.map((s) => ({ chainage_m: s.chainage_m, elevation_m: s.plan_z })),
balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 },
};
const detailOf = (withLine) => ({
longitudinal: withLine
? { length_m: 1000, stations: [], profile_alignment: { policy: {} } }
: { length_m: 1000, stations: [] },
cross_sections: job.sections.map(({ plan_z, ...section }) => ({
...structuredClone(section),
design: {
ground_type: "ripping_rock",
section_mode: job.mode,
design_elevation_m: plan_z,
},
})),
});
const pick = (design) => ({
ground_type: design.ground_type,
two_stage_slope: design.two_stage_slope ?? null,
ditch_enabled: design.ditch_enabled ?? null,
cut: design.cut_area_m2 ?? null,
fill: design.fill_area_m2 ?? null,
line: JSON.stringify(design.design_line ?? null),
});
/** 카드 단추 줄 차례 — 처음 셈 → 복합경사 켬 → 측구 끔 → 지반 흙. `shift` = 묶음 측점 어긋남(m). */
async function toggles(projectId, shift) {
m.writeState("std-cross", job.standard, projectId);
m.rememberRockBoundaryDefault(projectId, job.rockDefault);
const detail = detailOf(true);
const target = detail.cross_sections[job.target];
const stations = detail.cross_sections.map((s, i) => ({
key: `s${i}`,
chainageM: s.chainage_m + shift,
}));
const steps = [];
const run = async (tag) => {
const updated = [];
const out = await m.refreshSavedCross(
{ projectId, routeId: R, detail, edits: EDITS, alignment },
stations,
(rows) => updated.push(...rows),
);
steps.push({
tag,
out: out ? Object.keys(out.byKey).length : null,
updated,
...pick(target.design),
});
};
await run("open");
const choice = {
ground_type: "ripping_rock",
section_mode: job.mode,
ditch_side: null,
ditch_type: "standard",
paved: false,
two_stage_slope: false,
ditch_choice: null,
};
for (const [tag, over] of [
["two_stage", { two_stage_slope: true }],
["ditch_off", { ditch_choice: false }],
["soil", { ground_type: "soil" }],
]) {
Object.assign(choice, over);
m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice });
await run(tag);
}
return { target: target.chainage_m, steps };
}
const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b", 0.002) };
/* 서버 폴백(선형 저장분 없음) — 카드 선택이 요청에 실림 · 열쇠 = 측점 자리 */
{
const projectId = "p266c";
m.writeState("std-cross", job.standard, projectId);
const detail = detailOf(false);
const at = detail.cross_sections[job.target].chainage_m;
m.writeCrossDesignChoice(projectId, R, at, {
ground_type: "soil",
section_mode: job.mode,
two_stage_slope: true,
ditch_choice: false,
});
await m.refreshSavedCross({ projectId, routeId: R, detail, edits: EDITS }, [], () => undefined);
out.server = { at, choices: previews.at(-1)?.design_choices ?? null };
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,5 @@
/* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */
export { refreshSavedCross } from "../../A00_Common/design/design_shared";
export { writeCrossDesignChoice } from "../../B06_Section/B06_Section_Cross_Design_Session";
export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel";
export { writeState } from "../../A00_Common/b_page_state";
@@ -0,0 +1,137 @@
"""PLAN 439 — B06 카드 머리 줄 단추가 저장 노선에서 늘 반영됨(요구 266).
못박는 것
- 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 —
복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜.
- 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침.
- 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음.
짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` ·
지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for path in (ROOT, HERE):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from test_346_2_pipeline_cross_page import _fake, _line, _sections # noqa: E402
from B06_Section.B06_Section_Router import _with_design_choices # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler # noqa: E402
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
CUT_DEPTH_M = 3.0
@pytest.fixture(scope="module")
def r(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b439")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_266_card_toggle_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_266_card_toggle_entry.js", bundle)
with tempfile.TemporaryDirectory() as folder:
npz, nodes = _fake(Path(folder))
sampler = DtmGridSampler.from_npz(npz)
rows = _sections(_line(nodes), sampler, [])["cross_sections"]
middle = len(rows) // 2
picked = rows[middle - 1 : middle + 2]
sections = [{**s, "plan_z": float(s["center_z"]) - CUT_DEPTH_M} for s in picked]
job = {
"sections": sections,
"target": 1,
"mode": f"{picked[1].get('uphill_side') or 'left'}_cut",
"standard": STANDARD_CROSS_SECTION,
"rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
}
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_266_card_toggle.mjs"), str(bundle), str(path)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _check_toggles(case: dict, returned) -> None:
steps = {step["tag"]: step for step in case["steps"]}
assert steps["open"]["cut"] > 0.5 # 깎기 측점
before = steps["open"]
for tag in ("two_stage", "ditch_off", "soil"):
step = steps[tag]
assert case["target"] in step["updated"], tag # 카드 다시 그림 대상
assert step["line"] != before["line"], tag # 카드 설계선 바뀜
assert step["out"] == returned, tag
before = step
assert steps["two_stage"]["two_stage_slope"] is True
assert steps["ditch_off"]["ditch_enabled"] is False
assert steps["soil"]["ground_type"] == "soil"
assert steps["soil"]["cut"] != steps["open"]["cut"] # 면적 바뀜
def test_측점_같음_카드_단추마다_설계선_면적_바뀜_돌려줌(r):
_check_toggles(r["same"], 3)
def test_측점_어긋남_카드_자료는_고침_돌려줄_값만_null(r):
_check_toggles(r["mismatch"], None)
def test_서버_폴백에_카드_선택_실음(r):
key = f"{r['server']['at']:.3f}"
choice = (r["server"]["choices"] or {}).get(key)
assert choice is not None, r["server"]
assert choice["ground_type"] == "soil"
assert choice["two_stage_slope"] is True
assert choice["ditch_choice"] is False
def test_서버_카드_선택이_저장분_위에_얹힘():
stored = [{"chainage_m": 40.0, "design": {"ground_type": "ripping_rock", "berm": {"w": 1}}}]
rows = _with_design_choices(stored, {"40.000": {"ground_type": "soil"}, "60.123": {"x": 1}})
by_key = {round(float(r["chainage_m"]), 3): r["design"] for r in rows} # 서버 읽는 꼴
assert by_key[40.0] == {"ground_type": "soil", "berm": {"w": 1}}
assert by_key[60.123] == {"x": 1}
assert _with_design_choices(stored, None) == stored