Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-10-11 14:06:07 +09:00
24 changed files with 1070 additions and 49 deletions
+2
View File
@@ -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();
+1 -1
View File
@@ -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];
}
+1 -1
View File
@@ -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),
}
+102
View File
@@ -0,0 +1,102 @@
"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본).
식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림)
q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es)
t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6
L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0.
로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 ·
`own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김.
기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름).
"""
from __future__ import annotations
import math
from typing import Any
DEFAULTS: dict[str, float] = {
"L1": 0.0,
"L2": 0.0,
"V1": 30.0,
"V2": 35.0,
"V3": 10.0,
"V4": 15.0,
"T": 15.0,
"rt": 1.7,
"f": 1.0,
"qs": 1.72,
"k": 1.0,
"Es": 0.6,
"Cms": 37.4,
"t3": 0.8,
"t4": 0.42,
"t5": 0.5,
"t6": 1.5,
}
#: 로직 입력 이름 → 셈 결과 칸
FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"}
def r2(value: float) -> float:
"""소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
return math.floor(value * 100 + 0.5) / 100
def _div(a: float, b: float) -> float:
return a / b if b else 0.0
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]:
"""사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}."""
given = {k: _num(v) for k, v in (inputs or {}).items()}
v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()}
q = r2(_div(v["T"], v["rt"]) * v["f"])
n = r2(_div(q, v["qs"] * v["k"]))
t1 = given.get("t1")
t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1
l1, l2 = v["L1"], v["L2"]
t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60)
cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"])
return {
"q": q,
"n": n,
"t1": t1,
"t2": t2,
"cm": cm,
"L": math.floor((l1 + l2) * 1000 + 0.5) / 1000,
}
def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]:
"""연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로."""
cycle = dict((ref or {}).get("cycle") or {})
own = set((ref or {}).get("own") or [])
for name in ("L1", "L2"):
if name not in own and _num((dist or {}).get(name)) is not None:
cycle[name] = dist[name]
if "t1" not in own:
cycle.pop("t1", None)
return cycle
def fill_given(
ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None
) -> dict[str, Any]:
"""로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로."""
own = set(ref.get("own") or [])
got = cycle_time(cycle_inputs(ref, dist))
given = dict(ref.get("given") or {})
for name, slot in FILL.items():
if name in names and name not in own:
given[name] = got[slot]
return given
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* B08_Quantity_Haul_Cycle.ts
* B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
*
* ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
* 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본).
*
* 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) ·
* t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 ·
* 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 ·
* `own` = 사용자가 덮은 칸(그 값이 이김).
* ========================================================================== */
export const CYCLE_DEFAULTS: Record<string, number> = {
L1: 0,
L2: 0,
V1: 30,
V2: 35,
V3: 10,
V4: 15,
T: 15,
rt: 1.7,
f: 1,
qs: 1.72,
k: 1,
Es: 0.6,
Cms: 37.4,
t3: 0.8,
t4: 0.42,
t5: 0.5,
t6: 1.5,
};
/** 로직 입력 이름 → 셈 결과 칸 */
export const CYCLE_FILL: Record<string, "cm" | "t2" | "L"> = {
Cm: "cm",
사이클시간: "cm",
t2: "t2",
운반거리: "L",
};
export interface CycleResult {
q: number;
n: number;
t1: number;
t2: number;
cm: number;
L: number;
}
export interface CycleRef {
given?: Record<string, unknown> | null;
cycle?: Record<string, unknown> | null;
own?: string[] | null;
}
export type Dist = { L1?: number | null; L2?: number | null } | null | undefined;
/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */
export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100;
const div = (a: number, b: number): number => (b ? a / b : 0);
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */
export function cycleTime(inputs: Record<string, unknown> | null | undefined): CycleResult {
const v: Record<string, number> = {};
for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d;
const q = r2(div(v.T, v.rt) * v.f);
const n = r2(div(q, v.qs * v.k));
const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es));
const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60);
const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6);
return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 };
}
/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */
export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record<string, unknown> {
const cycle: Record<string, unknown> = { ...(ref?.cycle || {}) };
const own = new Set(ref?.own || []);
for (const name of ["L1", "L2"] as const)
if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name];
if (!own.has("t1")) delete cycle.t1;
return cycle;
}
/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */
export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record<string, unknown> {
const own = new Set(ref.own || []);
const got = cycleTime(cycleInputs(ref, dist));
const given: Record<string, unknown> = { ...(ref.given || {}) };
for (const [name, slot] of Object.entries(CYCLE_FILL))
if (names.includes(name) && !own.has(name)) given[name] = got[slot];
return given;
}
+33 -13
View File
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
"""
from __future__ import annotations
@@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — [그림 변경]으로 넣으세요."
PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
@@ -45,17 +47,22 @@ class CaptureError(Exception):
"""까닭 한 줄(키 · 예외 원문 없음)."""
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
def page_html(
key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
level = view["level"] if view else 7
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
<div id="m" style="width:{w}px;height:{h}px"></div>
<script src="{sdk}" onerror="window.__fail=1"></script>
<script>window.kakao && kakao.maps.load(() => {{
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
D.marks.forEach((m) => {{
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
}});
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
if ({"false" if view else "true"}) {{
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
}}
}});</script></body></html>"""
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
key = os.environ.get(KAKAO_JS, "").strip()
try:
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
except CaptureError as error:
return {"saved": False, "reason": str(error)}
out_dir = Path(job["out"])
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
def make_route_image(
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
out_dir: Path,
route_id: str,
lines: list[Any],
marks: list[dict[str, Any]],
view: dict[str, int] | None = None,
what: str = "경로 그림",
tail: str = TAIL,
) -> dict[str, Any]:
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
key = os.environ.get(KAKAO_JS, "").strip()
if not key:
return {
"saved": False,
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
}
if not any(lines) and not marks:
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
with _lock:
got = spawn({**job, "origin": origin})
if not got.get("saved"):
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
return {"saved": True, "reason": None}
def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
"""장소 그림(256-1) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 id>.png`."""
return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
if __name__ == "__main__":
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
+109 -6
View File
@@ -9,6 +9,10 @@
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
지금 중점 측점(244-12 · 상자 작은 글씨)
@@ -24,6 +28,10 @@
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
(캐시 깨기).
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
@@ -34,9 +42,11 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import os
import threading
import urllib.error
import urllib.parse
import urllib.request
@@ -44,7 +54,7 @@ from pathlib import Path
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, ValidationError, model_validator
@@ -53,7 +63,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -71,6 +81,8 @@ PATH = "/{project_id}/quantity/milestone"
FOLDER = Path("B08_Quantity") / "milestone"
DATA_FILE = "milestone.json"
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
PLACE = "place" # 장소 그림 하위 폴더(256-1)
PRINTS = ".prints.json" # 그림 지문 {"place/<id>": 해시} — 바뀐 것만 다시 캡처
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀
@@ -233,15 +245,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]:
}
def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]:
"""좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = bases id."""
boxes = [
(b["id"], b.get("name") or "", b.get("lon"), b.get("lat"))
for b in [*data.get("routes", []), *data.get("bases", [])]
]
return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None]
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
site = site_info(root, data["site"]["mid_station_m"])
auto = images(root, AUTO)
place = images(root, PLACE)
return {
"data": data,
"site": site,
"table": compute_milestone(data, site["mid_default_m"]),
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
"auto_images": auto,
"place_images": place,
"place_times": {
stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items()
},
}
@@ -251,13 +277,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
atomic_write_json(path / DATA_FILE, data)
keep = {route["id"] for route in data["routes"]}
for sub in ("", AUTO):
for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})):
for stem, name in images(root, sub).items():
if stem not in keep:
if stem not in ids:
(path / name).unlink(missing_ok=True)
return payload(root, data)
def _print(*parts: Any) -> str:
"""지문 — 좌표 · 이름 해시(좌표 원문은 안 남김)."""
return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16]
def _read_prints(root: str | Path) -> dict[str, str]:
try:
return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _set_print(root: str | Path, key: str, value: str | None) -> None:
"""지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처)."""
with _prints_lock:
prints = _read_prints(root)
if prints.get(key) == value:
return
if value is None:
prints.pop(key, None)
else:
prints[key] = value
atomic_write_json(folder(root) / PRINTS, prints)
_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나
def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict:
"""장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}."""
got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat})
if got["saved"]:
_set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat))
return got
def refresh_images(root: str | Path) -> None:
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
실패 = 다음 [저장]에 다시."""
data = read_data(root)
prints = _read_prints(root)
have = images(root, PLACE)
for place_id, name, lon, lat in places(data):
if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
place_image(root, place_id, name, lon, lat)
def _route_id(route: str) -> str:
try:
return Route(id=route).id
@@ -281,9 +354,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]:
@router.put(PATH)
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
async def milestone_save(
project_id: UUID, body: Milestone, tasks: BackgroundTasks
) -> dict[str, Any]:
root = await _project_root(project_id)
return await asyncio.to_thread(save_data, root, body)
out = await asyncio.to_thread(save_data, root, body)
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
return out
@router.get(PATH + "/site-address")
@@ -396,6 +473,32 @@ async def milestone_image_auto(
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$"
@router.post(PATH + "/place-image")
async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict:
"""장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/<id>.png · 좌표는 저장 안 함."""
place_id = _route_id(id)
root = await _project_root(project_id)
got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat)
if not got["saved"]:
return {"name": None, "time": None, "reason": got["reason"]}
name = f"{PLACE}/{place_id}.png"
time_ms = int((folder(root) / name).stat().st_mtime * 1000)
return {"name": name, "time": time_ms, "reason": None}
@router.get(PATH + "/file")
async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)):
"""그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음)."""
root = await _project_root(project_id)
path = folder(root) / name
if not path.is_file():
raise HTTPException(status_code=404, detail="그림이 없습니다.")
return FileResponse(path)
KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
KAKAO_KEY = "KAKAO_REST_API_KEY"
@@ -13,6 +13,8 @@
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
* 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`)
* ========================================================================== */
@@ -42,6 +44,8 @@ interface Payload {
site: SiteInfo;
images: Record<string, string>;
auto_images: Record<string, string>;
place_images: Record<string, string>;
place_times: Record<string, number>;
}
export const EMPTY: MilestoneData = {
@@ -227,6 +231,43 @@ export async function autoRouteImage(
changes.dispatchEvent(new Event("change"));
}
/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */
export const placeImageKey = (placeId: string): string | null => {
const name = saved?.place_images?.[placeId];
return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null;
};
/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */
export async function capturePlace(
projectId: string,
placeId: string,
mark: { name: string; lon: number; lat: number },
): Promise<void> {
let reason: string | null;
try {
const res = await request<{ name: string | null; time: number | null; reason: string | null }>(
`/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`,
{ method: "POST", body: JSON.stringify(mark) },
);
reason = res.reason;
if (res.name && saved) {
saved.place_images = { ...saved.place_images, [placeId]: res.name };
saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() };
}
} catch (error) {
reason = `장소 그림 캡처 요청 실패(${String(error)})`;
}
if (reason) showToast(reason, "warning");
changes.dispatchEvent(new Event("change"));
}
/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */
export async function placeImageBlobUrl(projectId: string, placeId: string): Promise<string> {
const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`;
const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`;
return URL.createObjectURL(await (await call(url)).blob());
}
/** 그림 → 화면 주소(blob · 쿠키 포함) */
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
+16 -1
View File
@@ -37,6 +37,7 @@ from decimal import Decimal
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
from B09_Estimation.B09_Estimation_Store import ref_rows
from M01_MasterData import M01_MasterData_Store as m01
@@ -475,13 +476,27 @@ class Solver:
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
def _input_names(code: str) -> list[str]:
try:
return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []]
except mf.FormulaError:
return []
def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]:
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)]."""
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] ·
운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본)."""
out = []
dist = (user or {}).get("haul_dist") or {}
for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함
ref = row.get("ref") or {}
if ref.get("tab") != "logic" or not ref.get("code"):
continue
if row["key"] in dist:
ref = {
**ref,
"given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]),
}
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개
+9 -3
View File
@@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸
if isinstance(cycle, dict):
out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None}
own = ref.get("own") if isinstance(ref.get("own"), list) else []
out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40]
return out
return None
+22 -2
View File
@@ -30,9 +30,9 @@
visibility: hidden;
}
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */
[data-b09-table] thead th {
white-space: normal;
white-space: pre-line;
overflow-wrap: anywhere;
}
@@ -136,6 +136,26 @@
font-weight: var(--font-weight-bold);
}
/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp {
font-size: 0;
text-align: center;
}
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-warning);
vertical-align: middle;
}
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before {
background: var(--color-success);
}
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
.b09-estimation .ui-data-table__table tbody td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
+1 -2
View File
@@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
export const TAB_GROUPS: Array<[string, TabId[]]> = [
["공사원가계산서", ["cost"]],
["총괄설계내역서", ["total"]],
["설계내역서", ["bill"]],
["설계내역", ["total", "bill"]],
["일위대가", ["dg_list", "dg"]],
["단가산출", ["sg_list", "sg"]],
["환율및기초자료", ["basis"]],
+17 -3
View File
@@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const bill: Row = sheets.bill || { rows: [], totals: null };
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
const raw: DataRow[] = bill.rows;
// 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N)
const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결");
const rows: DataRow[] = raw.map((r: Row) => ({
...r,
name: r.no ? `${r.no} ${r.name}` : r.name,
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "",
connect: linkable(r) ? `${linkText(r)} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
@@ -57,7 +59,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("connect", "단가연결", 96),
{ ...text("connect", "단가\n연결", 44), title: "단가연결" },
text("note", "비고", 200),
],
rows,
@@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => {
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
},
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
cellClass: (i, key) => {
const r = rows[i];
const lamp =
key === "connect" && r && linkable(raw[i])
? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
: "";
return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null;
},
tip: (i, key) => {
const r = rows[i];
if (r && key === "connect" && linkable(raw[i])) {
const auto = (r.auto_ref as Row | null)?.key ?? "빈칸";
const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : "";
return linkText(raw[i]) + back;
}
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
if (!r || !edited(r, key)) return null;
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
@@ -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));
+11 -12
View File
@@ -1,4 +1,4 @@
"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
"""181-1 B09 탭 두 층 — 위 탭 8(203-1 연결 점검 뺌 · 257-1 설계내역 한 묶음) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
@@ -19,8 +19,7 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
EXPECTED = [
["공사원가계산서", ["cost"]],
["총괄설계내역서", ["total"]],
["설계내역서", ["bill"]],
["설계내역", ["total", "bill"]],
["일위대가", ["dg_list", "dg"]],
["단가산출", ["sg_list", "sg"]],
["환율및기초자료", ["basis"]],
@@ -42,7 +41,7 @@ def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩():
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
firsts = list(dict.fromkeys(out["of"]))
assert firsts == [g[0] for g in EXPECTED]
assert len(firsts) == 9
assert len(firsts) == 8
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
@@ -60,26 +59,26 @@ const view = () => ({
});
bars.draw('dg');
const a = view();
bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
bars.top.children[2].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
bars.sub.children[0].click(); // 일위대가목록표
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
const b = view();
bars.draw('sum_material');
bars.draw('dg');
bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭
bars.top.children[6].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
bars.top.children[5].click(); // 중기 = 본 적 없음 → 첫 아래 탭
bars.draw('cost');
const c = view();
bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭
bars.top.children[1].click(); // 설계내역 = 첫 아래 탭(총괄)
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
console.log(JSON.stringify({ a, b, c, picks }));
""",
)
a, b, c = out["a"], out["b"], out["c"]
assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출"
assert len(a["top"]) == 8 and [t[0] for t in a["top"]][:5] == [
"공사원가계산서", "설계내역", "일위대가", "단가산출", "환율및기초자료"
] # fmt: skip
assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5
assert [t[2] for t in a["top"]] == ["false"] * 2 + ["true"] + ["false"] * 5
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
assert not a["blank"] and a["role"] == "tablist"
@@ -88,7 +87,7 @@ console.log(JSON.stringify({ a, b, c, picks }));
["자재단가대비표", "true", "compare"],
["재료비수량금액집계표", "false", "sum_material"],
] # fmt: skip
assert b["top"][7][2] == "true" and b["top"][3][2] == "false"
assert b["top"][6][2] == "true" and b["top"][2][2] == "false"
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None
@@ -75,7 +75,7 @@ console.log(JSON.stringify({
assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"]
assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로)
assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"]
assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구)
assert not out["subBlank"] # 257-1 설계내역 = 아래 탭 둘(총괄 · 설계내역서) · 빈 보조 줄 아님
assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림
assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로
assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음
@@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
cap.page_html = lambda key, lines, marks: HTML
cap.page_html = lambda key, lines, marks, view=None: HTML
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
sys.exit(0)
+114
View File
@@ -0,0 +1,114 @@
"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 ·
로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움."""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402
from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402
from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402
XLSX = next(
(ROOT / "resources/knowledge/original/실무문서").glob(
"01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx"
),
None,
)
#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤))
CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3",
"AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs",
"Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4",
"AK1337": "t5", "AW1337": "t6"} # fmt: skip
@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음")
def test_거창_혼합골재_운반_재현():
import openpyxl
ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"]
inputs = {}
for cell, name in CELLS.items():
text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######")
inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1))
# 식 줄이 같은 꼴인지(t2 · Cm)
assert (
"(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value
)
assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value
assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8)
got = cycle_time(inputs)
assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33)
assert (got["t2"], got["cm"]) == (101.23, 109.78)
assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값
CASES = [
{"L1": 25.1, "L2": 0.8},
{"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77},
{"L1": 0, "L2": 0, "t1": 2.5, "V3": 0},
{"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75},
]
def test_거울_ts():
calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts"
body = (
f"\nconst cases = {json.dumps(CASES)};"
"\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));"
"\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};"
"\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});"
"\nconsole.log(JSON.stringify(got));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines())
assert ts == [cycle_time(c) for c in CASES]
ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]}
assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8})
def test_입력_이름_채움():
ref = {"given": {"qt": 15}, "cycle": {}}
got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8})
assert got == {"qt": 15, "Cm": 109.78}
assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9}
assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == {
"qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip
# 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값
own = {"given": {"Cm": 50}, "own": ["Cm"]}
assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50}
mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]}
assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8}
def test_서버_셈_거리로_다시_채움():
ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}}
user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}}
((_key, _use, src),) = hand_sources(user)
assert src["given"]["Cm"] == 109.78
user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감
((_key, _use, src),) = hand_sources(user)
assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만
def test_덮개_ref_사이클_남김():
from B09_Estimation.B09_Estimation_Store import _clean_ref
ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"},
"own": ["Cm", 3]} # fmt: skip
got = _clean_ref(ref)
assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"]
rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}})
assert rows[0]["ref"]["cycle"] == {"V1": 20}
@@ -0,0 +1,160 @@
"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
캡처는 가짜(make_place_image 자리)로:
① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/<id>.png ·
GET 답 place_images · place_times
③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음
④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음)
"""
import sys
from pathlib import Path
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
PNG = b"\x89PNG\r\n\x1a\nfake-place"
DATA = {
"base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68},
"routes": [
{"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866},
{"id": "r2", "name": "좌표 없음"},
],
}
class Fake:
"""가짜 캡처 — 부른 상자 기록 · ok=False 면 실패."""
def __init__(self):
self.calls = []
self.ok = True
def __call__(self, out_dir, place_id, mark):
self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"]))
if not self.ok:
return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"}
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{place_id}.png").write_bytes(PNG)
return {"saved": True, "reason": None}
def _save(root, data):
return ms.save_data(root, ms.Milestone.model_validate(data))
def _fake(monkeypatch):
fake = Fake()
monkeypatch.setattr(ms, "make_place_image", fake)
return fake
def test_장소_그림_페이지_작은_지도():
html = cap.page_html(
"k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW
)
assert "width:480px;height:320px" in html and "level: 4" in html
assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌
assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로
def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch):
fake = _fake(monkeypatch)
_save(tmp_path, DATA)
ms.refresh_images(tmp_path)
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
got = ms.payload(tmp_path, ms.read_data(tmp_path))
assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"}
assert set(got["place_times"]) == {"b1", "r1"}
assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values())
assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG
prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8")
assert "127.9095" not in prints and "35.6866" not in prints # 해시만
def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch):
fake = _fake(monkeypatch)
_save(tmp_path, DATA)
ms.refresh_images(tmp_path)
fake.calls.clear()
ms.refresh_images(tmp_path)
assert fake.calls == [] # 안 바뀜 = 캡처 0
moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]}
_save(tmp_path, moved)
ms.refresh_images(tmp_path)
assert [c[0] for c in fake.calls] == ["r1"]
fake.calls.clear()
(tmp_path / "B08_Quantity/milestone/place/b1.png").unlink()
ms.refresh_images(tmp_path)
assert [c[0] for c in fake.calls] == ["b1"]
# 실패 = 지문 안 적음 → 다음에 다시
fake.calls.clear()
fake.ok = False
_save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}})
ms.refresh_images(tmp_path)
ms.refresh_images(tmp_path)
assert [c[0] for c in fake.calls] == ["b1", "b1"]
def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch):
_fake(monkeypatch)
_save(tmp_path, DATA)
ms.refresh_images(tmp_path)
got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움
assert got["place_images"] == {} and got["place_times"] == {}
assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir())
def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
fake = _fake(monkeypatch)
async def root(project_id):
return tmp_path
monkeypatch.setattr(ms, "_project_root", root)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
base = f"/api/projects/{uuid4()}/quantity/milestone"
# [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄)
assert client.put(base, json=DATA).status_code == 200
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
assert client.get(base).json()["place_images"]["r1"] == "place/r1.png"
# 등록 · 끌기 뒤 화면이 부름
fake.calls.clear()
mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5}
res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json()
assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0
assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)]
fake.ok = False
bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json()
assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"}
assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400
# 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음
assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG
assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
assert client.get(base + "/file", params={"name": name}).status_code == 422, name
def test_여러_기준점_상자_id(tmp_path, monkeypatch):
fake = _fake(monkeypatch)
bases = [
{"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"},
{"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52},
{"id": "b3", "name": "좌표 없음"},
]
_save(tmp_path, {"bases": bases, "routes": [{**DATA["routes"][0], "to": "b1"}]})
ms.refresh_images(tmp_path)
assert sorted(c[0] for c in fake.calls) == ["b1", "b2", "r1"]
got = _save(tmp_path, {"bases": bases[1:], "routes": []}) # b1 · r1 지움
assert got["place_images"] == {"b2": "place/b2.png"}
@@ -0,0 +1,58 @@
"""257-1 B09 위 탭 「설계내역」(총괄설계내역서 · 설계내역서 한 묶음) · 설계내역서 단가연결 칸 램프.
- 위 탭 = `TAB_GROUPS` 「설계내역」[total, bill] · 옛 두 위 탭 없음
- 단가연결 칸 = 줄 칸 class `b09-bill__lamp`(+ 연결 `is-linked`) · 풍선 = 글(대가N · 연결됨 · 단가연결)
- 머리 = 「단가↵연결」 · 폭 44 · 머리 CSS pre-line
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.test_184_3_b09_bill_menu import ROWS, SRC, _node, _ts # noqa: E402
JS = """
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser() {} };
const table = buildBill(ctx);
const at = (key) => table.rows.findIndex((r) => r.key === key);
const keys = ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"];
const col = table.columns.find((c) => c.key === "connect");
console.log(JSON.stringify({
col: { label: col.label, width: col.width, title: col.title },
cls: keys.map((k) => table.cellClass(at(k), "connect")),
tip: keys.map((k) => table.tip(at(k), "connect")),
other: table.cellClass(at("earth:4"), "qty"),
groups: TAB_GROUPS.map(([label, tabs]) => [label, tabs]),
}));
"""
def test_램프_칸_class_풍선_머리():
body = JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False))
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
assert out["col"] == {"label": "단가\n연결", "width": 44, "title": "단가연결"}
assert out["cls"] == [
"b09-bill__lamp is-linked", # 자동 줄 · 호표 연결
"b09-bill__lamp", # 연결 없음 = 노랑
"b09-bill__lamp is-linked", # 손 줄 · 비고 대가3
None, # 자재 줄 = 연결 대상 아님
None,
None, # 묶음 줄
]
assert out["tip"] == ["연결됨", "단가연결", "대가3", None, None, None]
assert out["other"] is None
labels = [g[0] for g in out["groups"]]
assert "설계내역" in labels and "총괄설계내역서" not in labels and "설계내역서" not in labels
assert ["설계내역", ["total", "bill"]] in out["groups"]
def test_CSS_램프_초록_노랑_머리_줄바꿈():
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
assert "td.b09-bill__lamp::before" in css and "var(--color-warning)" in css
assert "td.b09-bill__lamp.is-linked::before" in css and "var(--color-success)" in css
head = css.split("[data-b09-table] thead th {", 1)[1].split("}", 1)[0]
assert "white-space: pre-line" in head
+115
View File
@@ -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)