Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
|
||||
moved = false;
|
||||
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
|
||||
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -638,7 +638,7 @@ export type CrossCellSource =
|
||||
|
||||
/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */
|
||||
export interface CrossPickPiece {
|
||||
line: "design" | "ground" | "spoil" | "structure";
|
||||
line: "design" | "ground" | "spoil" | "structure" | "rock";
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel):
|
||||
class CrossPickPiece(BaseModel):
|
||||
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
|
||||
|
||||
line: Literal["design", "ground", "spoil", "structure"]
|
||||
line: Literal["design", "ground", "spoil", "structure", "rock"]
|
||||
start: list[float] = Field(..., min_length=2, max_length=2)
|
||||
end: list[float] = Field(..., min_length=2, max_length=2)
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray<SectionSample> | undefined): P
|
||||
const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) =>
|
||||
(raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite));
|
||||
|
||||
/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */
|
||||
export function rockRuns(
|
||||
design: CrossDesign | undefined,
|
||||
samples: ReadonlyArray<SectionSample> | undefined,
|
||||
): Pt[][] {
|
||||
const offset = design?.rock_boundary_offset_m;
|
||||
if (design?.geometry_preset !== "rock" || typeof offset !== "number") return [];
|
||||
return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset]));
|
||||
}
|
||||
|
||||
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
|
||||
export function pickLines(
|
||||
design: CrossDesign | undefined,
|
||||
@@ -53,6 +63,7 @@ export function pickLines(
|
||||
design: [polyline(design?.design_line)],
|
||||
ground: groundRuns(samples),
|
||||
spoil: [polyline(design?.spoil_fill_line)],
|
||||
rock: rockRuns(design, samples),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Select.ts
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
|
||||
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
||||
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
||||
@@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
else if (el.classList.contains("b06-chart__rock-boundary"))
|
||||
others.push({ line: "rock", points });
|
||||
}
|
||||
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
|
||||
line: piece.structure ? "structure" : "design",
|
||||
@@ -102,6 +104,7 @@ const LINE_NAME: Record<SplitPiece["line"], string> = {
|
||||
structure: "구조물 선",
|
||||
ground: "원지반선",
|
||||
spoil: "사토장선",
|
||||
rock: "암 경계선",
|
||||
};
|
||||
|
||||
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
||||
@@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
|
||||
const [piece] = pieces;
|
||||
const from = piece.points[0];
|
||||
const to = piece.points[piece.points.length - 1];
|
||||
if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil")
|
||||
if (
|
||||
design &&
|
||||
piece.points.length === 2 &&
|
||||
(piece.line === "design" || piece.line === "structure")
|
||||
)
|
||||
return pieceLabel(design, {
|
||||
from: { offset: from[0], elevation: from[1] },
|
||||
to: { offset: to[0], elevation: to[1] },
|
||||
|
||||
@@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None:
|
||||
return area if area > 1e-9 else None
|
||||
|
||||
|
||||
def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]:
|
||||
"""암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙)."""
|
||||
offset = _num(design.get("rock_boundary_offset_m"))
|
||||
if design.get("geometry_preset") != "rock" or offset is None:
|
||||
return []
|
||||
return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)]
|
||||
|
||||
|
||||
def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]:
|
||||
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선)."""
|
||||
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선)."""
|
||||
return {
|
||||
"design": [_design_line(design)],
|
||||
"ground": ground_runs(samples),
|
||||
"spoil": [_polyline(design.get("spoil_fill_line"))],
|
||||
"rock": rock_runs(design, samples),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} ·
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
|
||||
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
|
||||
|
||||
const STUBS = {
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
|
||||
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
|
||||
const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts"));
|
||||
|
||||
const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = {};
|
||||
|
||||
/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */
|
||||
function panCase(panStart) {
|
||||
const listeners = {};
|
||||
const svg = {
|
||||
addEventListener: (name, fn) => (listeners[name] ??= []).push(fn),
|
||||
setAttribute: () => {},
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
setPointerCapture: () => {},
|
||||
releasePointerCapture: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }),
|
||||
};
|
||||
const layer = { setAttribute: () => {} };
|
||||
Z.attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: 600,
|
||||
heightPx: 300,
|
||||
pad: { left: 10, right: 10, top: 10, bottom: 10 },
|
||||
content: { x: -600, y: -300, width: 1800, height: 900 },
|
||||
panStart,
|
||||
});
|
||||
const fire = (name, init = {}) => {
|
||||
let stopped = false;
|
||||
const event = {
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
pointerId: 1,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => (stopped = true),
|
||||
...init,
|
||||
};
|
||||
(listeners[name] ?? []).forEach((fn) => fn(event));
|
||||
return stopped;
|
||||
};
|
||||
const drag = (button) => {
|
||||
fire("pointerdown", { button, clientX: 100, clientY: 100 });
|
||||
fire("pointermove", { button, clientX: 140, clientY: 120 });
|
||||
fire("pointerup", { button, clientX: 140, clientY: 120 });
|
||||
};
|
||||
const click = (x = 50) => {
|
||||
fire("pointerdown", { clientX: x, clientY: 50 });
|
||||
fire("pointerup", { clientX: x, clientY: 50 });
|
||||
return fire("click", { clientX: x, clientY: 50 });
|
||||
};
|
||||
return { drag, click, fire };
|
||||
}
|
||||
{
|
||||
const middle = panCase();
|
||||
middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick)
|
||||
out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() };
|
||||
// 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과)
|
||||
const left = panCase((event) => event.button === 1 || event.button === 0);
|
||||
left.drag(0);
|
||||
// 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함
|
||||
out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() };
|
||||
}
|
||||
|
||||
/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */
|
||||
const px = ([offset, elevation]) =>
|
||||
[
|
||||
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
|
||||
CROSS_PAD.top +
|
||||
(axes.displayMax -
|
||||
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
|
||||
axes.pixelsPerMeter,
|
||||
].join(",");
|
||||
const el = (classes, points, attrs = {}) => ({
|
||||
tagName: "polyline",
|
||||
classList: { contains: (name) => classes.includes(name) },
|
||||
hasAttribute: (name) => name in attrs,
|
||||
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
|
||||
});
|
||||
const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]);
|
||||
const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]);
|
||||
const rockPts = G.rockRuns(design, samples)[0];
|
||||
const elements = [
|
||||
el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }),
|
||||
el(["b06-chart__cross-profile"], groundPts),
|
||||
el(["b06-chart__rock-boundary"], rockPts),
|
||||
];
|
||||
const pieces = S.collectPieces(elements, axes);
|
||||
out.pieces = pieces;
|
||||
const rockPieces = pieces.filter((p) => p.line === "rock");
|
||||
out.rockLabel = S.selectionLabel(design, [rockPieces[1]]);
|
||||
out.rockSource = S.selectionSource("length", [rockPieces[1]]);
|
||||
out.rockLocal = T.localCellValue(out.rockSource, design, samples);
|
||||
out.rockLength = G.pathLength(rockPieces[1].points);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조.
|
||||
|
||||
못박는 것
|
||||
- 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 ·
|
||||
거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음.
|
||||
- 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 ·
|
||||
암 프리셋 아니면 다시 고름.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)]
|
||||
DESIGN = {
|
||||
"design_elevation_m": 98.0,
|
||||
"geometry_preset": "rock",
|
||||
"rock_boundary_offset_m": -1.0,
|
||||
"road_edges": {
|
||||
"left": {"offset_m": 2.0, "elevation_m": 98.0},
|
||||
"right": {"offset_m": -2.0, "elevation_m": 98.0},
|
||||
},
|
||||
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE],
|
||||
}
|
||||
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)]
|
||||
AXES = {
|
||||
"widthPx": 600,
|
||||
"heightPx": 300,
|
||||
"pixelsPerMeter": 10,
|
||||
"maxOffset": 14,
|
||||
"displayMax": 110,
|
||||
"elevationMid": 100,
|
||||
"exaggeration": 2,
|
||||
}
|
||||
ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_259_detail_region.cjs")],
|
||||
input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
cwd=os.fspath(ROOT),
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 259-1 공용 팬 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out):
|
||||
assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False}
|
||||
|
||||
|
||||
def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out):
|
||||
assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False}
|
||||
|
||||
|
||||
# ── 259-1 암 경계선 나눔 · 고름 ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_암_경계선이_설계선과_만나는_점에서_나뉨(out):
|
||||
rock = [p for p in out["pieces"] if p["line"] == "rock"]
|
||||
assert len(rock) == 3
|
||||
ends = sorted(round(p["points"][0][0], 4) for p in rock)
|
||||
assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3)
|
||||
# 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점)
|
||||
design_ends = {
|
||||
round(q[0], 3)
|
||||
for p in out["pieces"]
|
||||
if p["line"] == "design"
|
||||
for q in (p["points"][0], p["points"][-1])
|
||||
}
|
||||
assert round(ROCK_X, 3) in design_ends
|
||||
|
||||
|
||||
def test_암_경계선_고름_값_저장_꼴(out):
|
||||
length = 2 * ROCK_X
|
||||
assert out["rockLength"] == pytest.approx(length, abs=1e-3)
|
||||
assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m"
|
||||
source = out["rockSource"]
|
||||
assert source["src"] == "length" and source["pieces"][0]["line"] == "rock"
|
||||
patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}})
|
||||
assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock"
|
||||
|
||||
|
||||
def test_암_경계선_서버_거울(out):
|
||||
number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES)
|
||||
assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6)
|
||||
assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]]
|
||||
# 토사 프리셋이면 암 경계선 없음 → 다시 고름
|
||||
soil = {**DESIGN, "geometry_preset": "soil"}
|
||||
assert pick_lines(soil, SAMPLES)["rock"] == []
|
||||
assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True)
|
||||
Reference in New Issue
Block a user