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

design_page.ts import 충돌 = 424 의 좌우 이동 import 지움 + 428 pipelineDetail 살림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-18 16:13:03 +09:00
co-authored by Claude Opus 5.5
10 changed files with 552 additions and 12 deletions
+180
View File
@@ -0,0 +1,180 @@
/* PLAN 428 헬퍼 — 옮긴 관(0.5 m 넘게)의 횡단: 측점 갈아끼움(기본 설계 + 만진 칸) → 저장 노선 다시 셈(미교차
* 넓힘) → 관 길이. 서버 길(기본 설계 · 넓힘)과 같은 값인지 시험이 견줌 · 관 초안 얹기(rebasePipeDraft).
* node helper_428_pipe_move.mjs <묶음.mjs> <job.json> <grid.bin>
* 짝 = `test_428_pipe_move_mirror.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);
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
const gridBytes = readFileSync(process.argv[4]);
// 설계 지형 격자 창구만 가짜 — 그 밖 요청은 404(관 초안 · 저장 관 없음)
globalThis.fetch = async (url) => {
const href = String(url);
if (href.includes("/route/terrain-sources"))
return Response.json({ design: "las", grid: { model_id: 1, smooth: false, signature: "s" } });
if (href.includes("/grid?")) return new Response(gridBytes);
return new Response("{}", { status: 404 });
};
const m = await import(pathToFileURL(process.argv[2]).href);
const P = "p428";
const R = 7;
m.writeState("std-cross", job.standard, P);
m.rememberRockBoundaryDefault(P, job.rockDefault);
/** 서버 횡단(표본 · 틀) → 묶음 측점 꼴(지반 = 중심 → 바깥 · 좌 +). */
const stationOf = (key, section) => {
const samples = section.samples;
const side = (sign) =>
samples
.filter((s) => s.offset_m * sign > 1e-9)
.map((s) => ({ offsetM: Math.abs(s.offset_m), z: s.elevation_m }))
.sort((a, b) => a.offsetM - b.offsetM);
const center = samples.find((s) => Math.abs(s.offset_m) < 1e-9);
return {
key,
kind: "pipe",
chainageM: section.chainage_m,
x: section.center_x,
y: section.center_y,
left: section.frame.left_xy,
azimuthDeg: section.azimuth_deg ?? 0,
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
ground: { left: side(1), right: side(-1), centerZ: center ? center.elevation_m : null },
};
};
const culvertAt = (chainage) => ({ ...job.culvert, chainage_m: chainage });
const alignmentAt = (chainage, z) => ({
stations: [{ chainage_m: chainage, plan_elevation_m: z }],
samples: [
{ chainage_m: 0, elevation_m: z },
{ chainage_m: 100000, elevation_m: z },
],
balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 },
});
const pipeLength = (section) =>
m.computeStoredLayouts(section, [section])?.culvert?.pipe?.lengthM ?? null;
const pick = (design) => ({
section_mode: design.section_mode,
ground_type: design.ground_type,
ditch_side: design.ditch_side ?? null,
two_stage_slope: design.two_stage_slope ?? null,
rock_boundary_offset_m: design.rock_boundary_offset_m ?? null,
inlet_structure: design.inlet_structure ?? null,
cut: design.cut_area_m2 ?? null,
fill: design.fill_area_m2 ?? null,
ends: design.design_line
? [design.design_line[0].offset_m, design.design_line.at(-1).offset_m]
: null,
});
const out = {};
const oldDetail = () => ({
longitudinal: { length_m: 1000, stations: [], profile_alignment: { policy: {} } },
cross_sections: [
{ ...structuredClone(job.old), kind: "irregular", culvert: culvertAt(job.old.chainage_m) },
],
});
/* 옮긴 관 — 0.5 m 넘게: 갈아끼움 → 다시 셈(넓힘) → 관 길이 */
{
const swap = m.swapCrossSections(
oldDetail(),
[stationOf("pipe:p-a", job.moved)],
["pipe:p-a"],
20,
);
const section = swap.detail.cross_sections[0];
out.swapped = { uphill: section.uphill_side, ...pick(section.design) };
section.culvert = culvertAt(job.moved.chainage_m); // 그림 자료가 새 자리에 다시 붙음(withDraftShapes 몫)
// 격자는 받기가 끝나야 씀 — 첫 다시 셈이 받기를 띄움 · 받은 뒤 다시 셈이 넓힘
await m.refreshCrossDesigns({
projectId: P,
routeId: R,
detail: swap.detail,
edits: { station_offsets: {}, curve_radii: {} },
alignment: alignmentAt(job.moved.chainage_m, job.elevation),
});
await new Promise((next) => setTimeout(next, 50));
out.firstSamples = section.samples.length; // 격자 받기 전 = 안 넓힘(지문에 격자 유무 · 받은 뒤 다시 셈)
await m.refreshCrossDesigns({
projectId: P,
routeId: R,
detail: swap.detail,
edits: { station_offsets: {}, curve_radii: {} },
alignment: alignmentAt(job.moved.chainage_m, job.elevation),
});
out.browser = {
...pick(section.design),
samples: section.samples.length,
sampleEnds: [section.samples[0].offset_m, section.samples.at(-1).offset_m],
pipeLength: pipeLength(section),
};
// 서버 = 같은 표본 · 기본 설계 → 서버 Node 가 같은 함수로 구조물 폐회로 면적 · 관 길이(`_recompute`)
const server = {
...structuredClone(job.moved),
kind: "irregular",
samples: job.serverSamples,
design: { ...job.serverDesign, inlet_structure: job.old.design.inlet_structure },
culvert: culvertAt(job.moved.chainage_m),
};
m.applyStructureAreaRows([server], m.structureAreaRows([server]));
out.server = { ...pick(server.design), pipeLength: pipeLength(server) };
}
/* 0.5 m 안 옮김 — 앞 설계 그대로(서버가 행을 옮겨 사용자 설계 값 그대로) */
{
const near = { ...stationOf("pipe:p-a", job.old), chainageM: job.old.chainage_m + 0.3 };
const swap = m.swapCrossSections(oldDetail(), [near], ["pipe:p-a"], 20);
out.near = pick(swap.detail.cross_sections[0].design);
}
/* 관 초안 얹기 — 따라가기만 한 관(땅 위 자리 = 정본) = 서버 누가거리 · 사용자가 옮긴 관 = 그대로 */
out.rebase = m
.rebasePipeDraft(
[
{ id: "f", chainage_m: 105, x: 10, y: 20, source: "user" },
{ id: "u", chainage_m: 210, x: 55, y: 66, source: "user" },
{ id: "s", chainage_m: 300, source: "user" },
],
[
{ id: "f", chainage_m: 100, x: 10, y: 20 },
{ id: "u", chainage_m: 200, x: 50, y: 60 },
{ id: "s", chainage_m: 300 },
],
[
{ id: "f", chainage_m: 104, x: 10.5, y: 20.5, source: "user" },
{ id: "u", chainage_m: 203, x: 51, y: 61, source: "user" },
{ id: "s", chainage_m: 301, x: 1, y: 1, source: "user" },
],
)
.map((pipe) => [pipe.id, pipe.chainage_m]);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,11 @@
/* PLAN 428 시험 묶음 진입점 — 옮긴 관 측점 갈아끼움 · 저장 노선 다시 셈(넓힘) · 관 길이 · 관 초안 얹기(진짜 모듈 · vite ssr). */
export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap";
export { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
export {
applyStructureAreaRows,
computeStoredLayouts,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel";
export { writeState } from "../../A00_Common/b_page_state";
export { rebasePipeDraft } from "../../common_util/common_util_route_follow";
+5 -1
View File
@@ -48,7 +48,11 @@ def test_암선_기본은_config(r):
def test_M5_관_길이는_공용_캐시(r):
assert r["m5"] == {"before": 8, "cleared": None, "after": 11}
page = _read("A00_Common/design/design_page.ts")
assert "peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections" in page
# 노선 초안 동안 = 계산 줄 새 노선 상세 · 그 밖 = 공용 캐시(428)
assert (
"(pipelineDetail(projectId) ?? peekSectionDetail(projectId, pageRouteId(projectId)))"
in page
)
found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["git", "grep", "-l", "setPipeSectionsSource(", "--", "*.ts"],
cwd=ROOT,
@@ -0,0 +1,228 @@
"""PLAN 428 — 평면 · 관 옮김 뒤 횡단 형상 · 측점 한 값.
요구 255-1 · 257-1 · 261 · 분석 424 1장 · 7장.
못박는 것
- 0.5 m 넘게 옮긴 관 측점 = 기본 설계(새 상단측 깎기 · 리핑암 · 표준 측구)
+ 사용자가 만진 칸 — 서버가 그 행을 지우고 기본 설계를 세우는 것과 같음 ·
0.5 m 안이면 앞 설계 그대로.
- 저장 노선 다시 셈(`refreshCrossDesigns`)이 미교차 측점을 서버 `extend_unclosed`
와 같이 넓힘 → 브라우저 관 길이 = 서버 관 길이(같은 기하 함수 · 표본 · 설계).
- 관 초안 얹기 — 따라가기만 한 관(땅 위 자리 = 정본 x · y) = 서버 누가거리 ·
사용자가 옮긴 관 = 그대로.
- 관 PUT 끝 서버가 측점을 다시 세움 · GET 관 지점에 x · y.
짝: `helper_428_pipe_move.mjs` · 진입점 `helper_428_pipe_move_entry.ts` ·
지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`.
"""
from __future__ import annotations
import inspect
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_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # 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="프론트엔드 의존성 미설치",
)
CULVERT = {
"type": "pipe",
"pipe_kind": "흄관",
"diameter_m": 1.0,
"min_cover_m": 0.5,
"inlet": {"role": "inlet", "structure": "기슭막이", "revet_form": "돌쌓기(찰)"},
"outlet": {"role": "outlet", "structure": "기슭막이", "revet_form": "돌쌓기(찰)"},
}
def _pipe_section(line, sampler, chainage: float) -> dict:
data = _sections(line, sampler, [{"id": "p-a", "chainage_m": chainage, "source": "user"}])
return next(s for s in data["cross_sections"] if abs(s["chainage_m"] - chainage) < 1e-6)
def _server(section: dict, elevation: float, sampler):
"""서버 길 — 옮긴 관 측점 = 기본 설계(상단측 깎기 · `compute_default_designs`)
→ [저장] 보정 맨 앞 미교차 넓힘(`extend_unclosed_sections` — 저장 설계의
단면 유형 · 측구 칸을 입력으로 다시 셈)."""
base = {
"ground_type": "ripping_rock",
"ditch_type": "standard",
"standard": STANDARD_CROSS_SECTION,
"rock_boundary_offset_m": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
}
stored = compute_cross_design(
section["samples"],
elevation,
section_mode=f"{section.get('uphill_side') or 'left'}_cut",
**base,
)
def compute(samples):
return compute_cross_design(
samples,
elevation,
section_mode=stored["section_mode"],
ditch_side=stored.get("ditch_side"),
ditch_enabled=stored.get("ditch_enabled"),
ditch_choice=stored.get("ditch_choice"),
two_stage_slope=bool(stored.get("two_stage_slope", False)),
**base,
)
wide = extend_unclosed(section["samples"], section["frame"], compute, sampler)
if not (wide and (wide[1]["left_m"] or wide[1]["right_m"])):
return section["samples"], stored, wide
return wide[0], compute(wide[0]), wide
@pytest.fixture(scope="module")
def r(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b428")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_428_pipe_move_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_428_pipe_move_entry.js", bundle)
with tempfile.TemporaryDirectory() as folder:
npz, nodes = _fake(Path(folder))
line = _line(nodes)
sampler = DtmGridSampler.from_npz(npz)
old = _pipe_section(line, sampler, 90.0)
moved = _pipe_section(line, sampler, 96.0) # 6 m 옮김 — 서버가 옛 행을 지우고 새로 세움
# 표본 20 m 밖까지 닿는 성토 높이 — 넓힘이 실제로 도는 자리
for rise in (10.0, 14.0, 18.0, 24.0):
elevation = float(moved["center_z"]) + rise
samples, design, wide = _server(moved, elevation, sampler)
if samples is not moved["samples"]:
break
else:
pytest.fail("넓힘이 도는 계획고를 못 찾음")
uphill = moved.get("uphill_side") or "left"
other = "right" if uphill == "left" else "left"
old["design"] = {
"design_elevation_m": elevation,
"ground_type": "soil",
"section_mode": f"{other}_cut",
"ditch_side": other,
"two_stage_slope": True,
"rock_boundary_offset_m": 9.0,
"inlet_structure": "revet",
}
job = {
"old": old,
"moved": moved,
"elevation": elevation,
"standard": STANDARD_CROSS_SECTION,
"rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
"culvert": CULVERT,
"serverSamples": samples,
"serverDesign": design,
}
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
grid = Path(folder) / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_428_pipe_move.mjs"), str(bundle), str(path), str(grid)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
out["job"] = {"uphill": uphill, "other": other, "wide": wide[1], "samples": len(samples)}
out["serverDesign"] = design
return out
def test_옮긴_관_측점_기본_설계와_만진_칸(r):
s = r["swapped"]
assert s["uphill"] == r["job"]["uphill"] # 상단측 = 새 지반
assert s["section_mode"] == f"{r['job']['uphill']}_cut"
assert s["ground_type"] == "ripping_rock"
assert s["ditch_side"] is None and s["two_stage_slope"] is None
assert s["rock_boundary_offset_m"] is None
assert s["inlet_structure"] == "revet" # 사용자가 만진 칸은 따라감
def test_0_5m_안_옮김_앞_설계_그대로(r):
n = r["near"]
assert n["section_mode"] == f"{r['job']['other']}_cut"
assert n["ground_type"] == "soil" and n["ditch_side"] == r["job"]["other"]
assert n["rock_boundary_offset_m"] == 9.0
def test_저장_노선_다시_셈이_서버처럼_넓힘(r):
b = r["browser"]
s = r["server"] # 구조물 폐회로 면적까지(서버 Node 와 같은 함수)
assert r["job"]["wide"]["left_m"] or r["job"]["wide"]["right_m"]
assert r["firstSamples"] < b["samples"] == r["job"]["samples"] # 격자 받은 뒤 넓힘
assert abs(b["cut"] - s["cut"]) < 1e-3 and abs(b["fill"] - s["fill"]) < 1e-3
d = r["serverDesign"]
ends = [d["design_line"][0]["offset_m"], d["design_line"][-1]["offset_m"]]
assert all(abs(a - e) < 1e-3 for a, e in zip(b["ends"], ends))
def test_옮긴_관_관_길이_브라우저_대_서버(r):
got = r["browser"]["pipeLength"]
want = r["server"]["pipeLength"]
assert got is not None and want is not None and got > 0
assert abs(got - want) < 1e-3, (got, want)
def test_관_초안_얹기_따라가기만_한_관은_서버_누가거리(r):
assert r["rebase"] == [["f", 104], ["u", 210], ["s", 301]]
def test_관_PUT_끝_측점_다시_세움_GET_좌표():
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
put = inspect.getsource(basins.put_pipe_points)
assert (
put.index("save_pipe_points") < put.index("_stand_pipe_stations(") < put.index("_payload(")
)
stand = inspect.getsource(basins._stand_pipe_stations)
assert "ensure_structure_stations(project_id)" in stand and "except Exception" in stand
raw = inspect.getsource(basins._raw_payload)
assert '"x": round(float(pipe.x), 3)' in raw and '"y": round(float(pipe.y), 3)' in raw