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

This commit is contained in:
2026-11-01 11:36:14 +09:00
33 changed files with 2429 additions and 331 deletions
+434
View File
@@ -0,0 +1,434 @@
/* =============================================================================
* map_guide_route.ts(logic)
* 예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — 기준지점을 차례로 거치는 참고용 선.
* 화면 가이드 전용 · 파이썬 짝 없음 · 저장 안 함(설계 값 아님 · 선례 `D03_PlanAnalysis_Fan.ts:8`).
* 2 m 격자 A*: 걸음 = 반지름 걸음/칸 ± 0.5 격자점(20 m = 56 방향) · |Δz| > 상한 × 걸음 길이 막음 ·
* 비용 = 길이 × (1 + 급경사 × 급경사 표본 비율 + 기울기 × (g/상한)²) + 꺾임 × 걸음 × (꺾임각/π)² ·
* 어림 = 직선 거리 × 가중(가중 A*) · 구간마다 상자 = 두 점 + 여유(못 닿으면 두 배 한 번) · 펼침 상한.
* 첫 걸음 = 정확한 시작점에서 · 끝 걸음도 기울기 검사 · 꼭짓점 그대로(20 m 다시 나누기 금지).
* 1 m 격자 = 2 칸 솎아 2 m · 급경사 = 격자 높이에서 바로 셈 · 못 닿음 = 가장 가까이 간 데까지.
* 근거 = `docs/raw/요구_1101/_분석_546-C_추천노선_라이다.md` C1 · DOM · fetch · three 없음.
* ========================================================================== */
import {
heightAt as gridHeightAt,
type DtmGrid,
} from "../../../common_util/common_util_surface_sampler";
export type Point2 = [number, number];
export type Point3 = [number, number, number];
/** 높이 원천 — heightAt(NaN = 밖 · 빈칸) · 원천 격자 칸(m) · 격자 원점(칸 꼭짓점 맞춤). */
export interface GuideTerrain {
heightAt: (x: number, y: number) => number;
/** 원천 격자 칸 m(기본 2) — 2 m 보다 잘면 솎아 2 m 안팎으로. */
cell?: number;
origin?: Point2;
}
export interface GuideWeights {
/** 급경사 표본 비율 벌점(기본 3) */
steep: number;
/** (g/상한)² 벌점(기본 0.3) */
grade: number;
/** 꺾임 벌점(기본 1) */
turn: number;
/** 어림 가중(기본 3 · 1 = 정확 A*) */
heuristic: number;
}
export interface GuideOptions {
/** 걸음 m(기본 20 · 그리기 「길이·기울기 고정」 칸) */
step?: number;
/** 기울기 상한 비율(기본 0.14) */
maxGrade?: number;
/** 급경사 문턱 %(기본 70 = 35° · 급경사 겹 둘째 경계) */
steepPct?: number;
weights?: Partial<GuideWeights>;
/** 구간마다 펼침 상한(기본 30 만 ≈ 0.4 s · 못 닿음 ≤ 0.5 s) */
maxExpand?: number;
/** 구간마다 시간 상한 ms(기본 450 · 못 닿음 ≤ 0.5 s · 느린 기계 막음) */
maxMs?: number;
}
export interface GuideSegment {
/** 꼭짓점 [x, y, z] — 닿음이면 끝 = 다음 지점 · 아니면 가장 가까이 간 데까지 */
vertices: Point3[];
reached: boolean;
/** 평면 길이 m */
length: number;
/** 걸음 가운데 가장 큰 |기울기| 비율 */
maxGrade: number;
/** 급경사(문턱 이상) 지나는 길이 비율 0~1 */
steepRatio: number;
expanded: number;
}
export interface GuideResult {
segments: GuideSegment[];
ms: number;
}
export const GUIDE_DEFAULTS = {
step: 20,
maxGrade: 0.14,
steepPct: 70,
weights: { steep: 3, grade: 0.3, turn: 1, heuristic: 3 } as GuideWeights,
maxExpand: 300_000,
maxMs: 450,
};
const MARGIN_MIN = 200;
const MARGIN_MAX = 800;
const STEEP_SAMPLES = [0.25, 0.5, 0.75, 1];
/** 서버 지형 격자(`parseDtmGrid`) → 가이드 높이 원천(원천 칸 · 원점 그대로). */
export function guideTerrainFromGrid(grid: DtmGrid): GuideTerrain {
const n = grid.x.length;
const cell = n > 1 ? (grid.x[n - 1] - grid.x[0]) / (n - 1) : 2;
return {
heightAt: (x, y) => gridHeightAt(grid, x, y),
cell,
origin: [grid.x[0], grid.y[0]],
};
}
/** 지점 차례로 잇는 가이드 선 — 지점 사이 구간마다 A*. */
export function guideRoute(
points: Point2[],
terrain: GuideTerrain,
options: GuideOptions = {},
): GuideResult {
const started = performance.now();
const segments: GuideSegment[] = [];
for (let index = 0; index + 1 < points.length; index++) {
segments.push(guideSegment(points[index], points[index + 1], terrain, options));
}
return { segments, ms: performance.now() - started };
}
/** 두 점 사이 가이드 선 하나. */
export function guideSegment(
start: Point2,
goal: Point2,
terrain: GuideTerrain,
options: GuideOptions = {},
): GuideSegment {
const step = options.step ?? GUIDE_DEFAULTS.step;
const gmax = options.maxGrade ?? GUIDE_DEFAULTS.maxGrade;
const weights = { ...GUIDE_DEFAULTS.weights, ...options.weights };
const steepGrade = (options.steepPct ?? GUIDE_DEFAULTS.steepPct) / 100;
const source = terrain.cell ?? 2;
const cell = source * Math.max(1, Math.round(2 / source));
const origin = terrain.origin ?? [0, 0];
const za = terrain.heightAt(start[0], start[1]);
const zb = terrain.heightAt(goal[0], goal[1]);
if (!Number.isFinite(za) || !Number.isFinite(zb)) {
return measure([[start[0], start[1], za]], false, 0, null);
}
const span = Math.hypot(goal[0] - start[0], goal[1] - start[1]);
const margin = Math.min(MARGIN_MAX, Math.max(MARGIN_MIN, span / 2));
const limit = options.maxExpand ?? GUIDE_DEFAULTS.maxExpand;
let spent = 0; // 두 상자 합친 펼침 수
let late = false;
const deadline = performance.now() + (options.maxMs ?? GUIDE_DEFAULTS.maxMs);
let edge = false; // 상자 끝에 닿음 = 넓히면 길이 있을 수 있음
let found = search(margin);
if (!found.reached && edge && spent < limit && !late) found = search(margin * 2);
found.expanded = spent;
return found;
function search(pad: number): GuideSegment {
const i0 = Math.floor((Math.min(start[0], goal[0]) - pad - origin[0]) / cell);
const j0 = Math.floor((Math.min(start[1], goal[1]) - pad - origin[1]) / cell);
const w = Math.ceil((Math.max(start[0], goal[0]) + pad - origin[0]) / cell) - i0 + 1;
const h = Math.ceil((Math.max(start[1], goal[1]) + pad - origin[1]) / cell) - j0 + 1;
const n = w * h;
const START = n;
const GOAL = n + 1;
const zs = new Float32Array(n).fill(Infinity); // Infinity = 아직 안 읽음 · NaN = 밖
const steepState = new Uint8Array(n); // 0 모름 · 1 급경사 · 2 아님
const g = new Float64Array(n + 2).fill(Infinity);
const parent = new Int32Array(n + 2).fill(-1);
const dir = new Float32Array(n + 2).fill(NaN);
const closed = new Uint8Array(n + 2);
const heap = new MinHeap();
const nx = (id: number) => origin[0] + (i0 + (id % w)) * cell;
const ny = (id: number) => origin[1] + (j0 + Math.floor(id / w)) * cell;
const zAt = (id: number): number => {
let value = zs[id];
if (value === Infinity) {
value = terrain.heightAt(nx(id), ny(id));
zs[id] = Number.isFinite(value) ? value : NaN;
}
return value;
};
const steepSq = (2 * cell * steepGrade) ** 2;
const steepAt = (id: number): boolean => {
if (steepState[id] === 0) {
const i = id % w;
const j = (id - i) / w;
if (i < 1 || j < 1 || i >= w - 1 || j >= h - 1) steepState[id] = 2;
else {
const gx = zAt(id + 1) - zAt(id - 1);
const gy = zAt(id + w) - zAt(id - w);
steepState[id] = gx * gx + gy * gy >= steepSq ? 1 : 2; // NaN = 아님
}
}
return steepState[id] === 1;
};
const radius = step / cell;
// 걸음 표 — 격자 차 · 길이 · 방위 · 급경사 표본 차(1/4 · 1/2 · 3/4 · 끝)
const offI: number[] = [];
const offJ: number[] = [];
const offLen: number[] = [];
const offDir: number[] = [];
const sampleD: number[] = []; // 칸 번호 차
const reach = Math.ceil(radius + 0.5);
for (let dj = -reach; dj <= reach; dj++) {
for (let di = -reach; di <= reach; di++) {
if (Math.abs(Math.hypot(di, dj) - radius) > 0.5) continue;
offI.push(di);
offJ.push(dj);
offLen.push(Math.hypot(di, dj) * cell);
offDir.push(Math.atan2(dj, di));
for (const t of STEEP_SAMPLES) {
sampleD.push(Math.round(dj * t) * w + Math.round(di * t));
}
}
}
const steepShare = weights.steep / STEEP_SAMPLES.length;
const turnWeight = (weights.turn * step) / (Math.PI * Math.PI);
// 걸음 비용 — 막히면 Infinity
const cost = (
ax: number,
ay: number,
az: number,
bx: number,
by: number,
bz: number,
inDir: number,
): number => {
const len = Math.hypot(bx - ax, by - ay);
const dz = Math.abs(bz - az);
if (!(dz <= gmax * len)) return Infinity;
let hits = 0;
for (const t of STEEP_SAMPLES) {
const si = Math.round((ax + (bx - ax) * t - origin[0]) / cell) - i0;
const sj = Math.round((ay + (by - ay) * t - origin[1]) / cell) - j0;
if (si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si)) hits++;
}
const grade = len > 0 ? dz / len / gmax : 0;
let value = len * (1 + steepShare * hits + weights.grade * grade * grade);
if (Number.isFinite(inDir) && len > 0) {
let turn = Math.abs(Math.atan2(by - ay, bx - ax) - inDir);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
value += turnWeight * turn * turn;
}
return value;
};
const relax = (
from: number,
to: number,
c: number,
ax: number,
ay: number,
bx: number,
by: number,
) => {
if (!Number.isFinite(c) || g[from] + c >= g[to]) return;
g[to] = g[from] + c;
parent[to] = from;
dir[to] = Math.atan2(by - ay, bx - ax);
heap.push(g[to] + weights.heuristic * Math.hypot(goal[0] - bx, goal[1] - by), to);
};
// 끝 걸음 — 한 걸음 안이면 정확한 끝점으로(기울기 검사)
const tryGoal = (from: number, ax: number, ay: number, az: number) => {
if (Math.hypot(goal[0] - ax, goal[1] - ay) <= step + 0.5 * cell) {
relax(
from,
GOAL,
cost(ax, ay, az, goal[0], goal[1], zb, dir[from]),
ax,
ay,
goal[0],
goal[1],
);
}
};
g[START] = 0;
tryGoal(START, start[0], start[1], za);
// 첫 걸음 — 정확한 시작점에서 반지름 걸음 고리
const ci = Math.round((start[0] - origin[0]) / cell) - i0;
const cj = Math.round((start[1] - origin[1]) / cell) - j0;
for (let j = cj - reach - 1; j <= cj + reach + 1; j++) {
for (let i = ci - reach - 1; i <= ci + reach + 1; i++) {
if (i < 0 || j < 0 || i >= w || j >= h) continue;
const id = j * w + i;
const bx = nx(id);
const by = ny(id);
if (Math.abs(Math.hypot(bx - start[0], by - start[1]) - step) > 0.5 * cell) continue;
const bz = zAt(id);
if (Number.isNaN(bz)) continue;
relax(START, id, cost(start[0], start[1], za, bx, by, bz, NaN), start[0], start[1], bx, by);
}
}
let best = START;
let bestDist = span;
while (heap.size > 0 && spent < limit && !late) {
const u = heap.pop();
if (closed[u]) continue;
closed[u] = 1;
if (u === GOAL) return finish(GOAL, true);
spent++;
if ((spent & 1023) === 0 && performance.now() > deadline) late = true;
const ui = u % w;
const uj = Math.floor(u / w);
const ux = nx(u);
const uy = ny(u);
const uz = zs[u];
const left = Math.hypot(goal[0] - ux, goal[1] - uy);
if (left < bestDist) {
bestDist = left;
best = u;
}
tryGoal(u, ux, uy, uz);
const gu = g[u];
const du = dir[u];
for (let k = 0; k < offI.length; k++) {
const vi = ui + offI[k];
const vj = uj + offJ[k];
if (vi < 0 || vj < 0 || vi >= w || vj >= h) {
edge = true;
continue;
}
const v = vj * w + vi;
if (closed[v]) continue;
let vz = zs[v];
if (vz === Infinity) vz = zAt(v);
if (vz !== vz) continue; // NaN = 밖
const len = offLen[k];
const dz = Math.abs(vz - uz);
if (dz > gmax * len) continue;
let hits = 0;
for (let s = 4 * k; s < 4 * k + 4; s++) {
const at = u + sampleD[s];
const state = steepState[at];
if (state === 1 || (state === 0 && steepAt(at))) hits++;
}
const grade = dz / len / gmax;
let c = len * (1 + steepShare * hits + weights.grade * grade * grade);
if (du === du) {
let turn = Math.abs(offDir[k] - du);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
c += turnWeight * turn * turn;
}
const next = gu + c;
if (next >= g[v]) continue;
g[v] = next;
parent[v] = u;
dir[v] = offDir[k];
const ex = goal[0] - (ux + offI[k] * cell);
const ey = goal[1] - (uy + offJ[k] * cell);
heap.push(next + weights.heuristic * Math.sqrt(ex * ex + ey * ey), v);
}
}
return finish(best, false);
function finish(last: number, reached: boolean): GuideSegment {
const chain: Point3[] = [];
for (let id = last; id !== -1; id = parent[id]) {
if (id === START) chain.push([start[0], start[1], za]);
else if (id === GOAL) chain.push([goal[0], goal[1], zb]);
else chain.push([nx(id), ny(id), zs[id]]);
}
chain.reverse();
return measure(chain, reached, 0, (x, y) => {
const si = Math.round((x - origin[0]) / cell) - i0;
const sj = Math.round((y - origin[1]) / cell) - j0;
return si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si);
});
}
}
// 길이 · 최대 기울기 · 급경사 지남 비율(걸음마다 칸 간격 표본)
function measure(
vertices: Point3[],
reached: boolean,
expanded: number,
steepAt: ((x: number, y: number) => boolean) | null,
): GuideSegment {
let length = 0;
let maxGrade = 0;
let steepLength = 0;
for (let k = 1; k < vertices.length; k++) {
const [ax, ay, az] = vertices[k - 1];
const [bx, by, bz] = vertices[k];
const len = Math.hypot(bx - ax, by - ay);
if (len <= 0) continue;
length += len;
maxGrade = Math.max(maxGrade, Math.abs(bz - az) / len);
if (!steepAt) continue;
const parts = Math.max(1, Math.ceil(len / cell));
for (let p = 0; p < parts; p++) {
const t = (p + 0.5) / parts;
if (steepAt(ax + (bx - ax) * t, ay + (by - ay) * t)) steepLength += len / parts;
}
}
return {
vertices,
reached,
length,
maxGrade,
steepRatio: length > 0 ? steepLength / length : 0,
expanded,
};
}
}
/** 이진 힙(값 작은 것 먼저) — 타입 배열 · 늘어남. */
class MinHeap {
private keys = new Float64Array(1024);
private ids = new Int32Array(1024);
size = 0;
push(key: number, id: number): void {
if (this.size === this.keys.length) {
const keys = new Float64Array(this.size * 2);
keys.set(this.keys);
const ids = new Int32Array(this.size * 2);
ids.set(this.ids);
this.keys = keys;
this.ids = ids;
}
let at = this.size++;
while (at > 0) {
const up = (at - 1) >> 1;
if (this.keys[up] <= key) break;
this.keys[at] = this.keys[up];
this.ids[at] = this.ids[up];
at = up;
}
this.keys[at] = key;
this.ids[at] = id;
}
pop(): number {
const top = this.ids[0];
const key = this.keys[--this.size];
const id = this.ids[this.size];
let at = 0;
for (;;) {
let child = 2 * at + 1;
if (child >= this.size) break;
if (child + 1 < this.size && this.keys[child + 1] < this.keys[child]) child++;
if (this.keys[child] >= key) break;
this.keys[at] = this.keys[child];
this.ids[at] = this.ids[child];
at = child;
}
this.keys[at] = key;
this.ids[at] = id;
return top;
}
}
+6 -3
View File
@@ -140,7 +140,8 @@ async def _company(cursor: Any, company_id: int) -> dict[str, Any]:
async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
"""예정노선 수 — 분석 전 = 노선을 아직 안 그은 프로젝트 · 분석됨 · 확정 = 연도(설계 내용 파일) ·
업체 지정 대기 = 확정 연도 중 그 연도 조각에 업체가 하나도 없는 것."""
업체 지정 대기 = 확정 연도 중 그 연도 세트에 업체가 하나도 없는 것(업체 줄 = 세트 열쇠 ·
옛 조각 id = 든 세트)."""
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
await cursor.execute(
@@ -161,11 +162,13 @@ async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
continue
if not state["plan"]["lines"]:
status["draft"] += 1
found = (store.find_unit(state["units"], key) for key in pieces)
held = {unit["id"] for unit in found if unit}
for year, row in state["years"].items():
key = "confirmed" if row["confirmed_at"] else "analyzed"
status[key] += 1
ids = {p["id"] for p in row["route"]["pieces"]}
waiting += bool(row["confirmed_at"] and not ids & pieces)
ids = {u["id"] for u in state["units"] if u["year"] == year}
waiting += bool(row["confirmed_at"] and not ids & held)
return {"plan_status": status, "assign_waiting": waiting}
+225
View File
@@ -0,0 +1,225 @@
"""B05 라이다 나중 올리기(PLAN 637-2 · 요구 546) — 프로젝트 만든 뒤 LAS 를 올려 지형 칩에 라이다.
- `POST /api/projects/{id}/route/lidar` — LAS/LAZ(+ .prj) multipart `files` → 뒤에서 짓기
(저장 · 설계 지형 바꿈 중 = 409 · 이미 돎 = 409)
- `GET …/route/lidar` — 진행 · 결과 `{ready · running · percent · message · error · interrupted ·
covered · covered_ratio · epsg · files · design · sources}`
짓는 자리 = 프로젝트 뿌리 `B04_PreProcess/models`(B05 표본이 읽는 자리) ·
입력 LAS = `B04_PreProcess/lidar_las/`(B03 원본 · 입력 행은 안 건드림) · 다시 올림 = 갈음.
길 = 공용 `common_util_lidar_upload`(예정노선과 같음 · `maps=False` = 지도 · GIS · 도엽 서피스
안 받음 → 수치지형도 모델 · 배수유역 그대로).
DB = `surface_models` 넣기/고치기만(`keep` — 갈음 때 id · 확정 · 노선 연결 살림 ·
`save_surface_analysis_to_db` 안 씀).
라이다 확정값 — 지금 설계가 라이다면 그 필터로 갈음 · 수치지형도면 `las_selection` 에 적음
(칩 라이다 열림 · 설계 지형은 그대로).
노선 · 노드 · 초기값 · 종단 · 횡단 안 바뀜 — 설계 값은 사용자가 지형 칩 확인창에서
`POST /route/design-terrain`(갈음 뒤 같은 지형 = `force`) 할 때만.
노선 덮임 = 저장 노선을 2 m 마다 새 지형에서 표본 — 빈칸 있으면 `covered` 거짓
(화면이 라이다 바꿈 막음).
일 동안 [저장]과 같은 프로젝트 잠금(`common_util_save_guard`).
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, File, HTTPException, UploadFile
from common_util import common_util_lidar_upload as lidar_upload
from common_util.common_util_json import atomic_write_json
from common_util.common_util_surface_confirmation import (
LAS_STASH_KEY,
analysis_terrain_sources,
design_terrain,
get_surface_confirmation_params,
stash_las_selection,
)
from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["B05 Route Lidar"])
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KIND = "lidar"
PRE = Path("B04_PreProcess")
MODELS = PRE / "models"
UPLOAD = PRE / "lidar_upload"
LAS_DIR = PRE / "lidar_las"
META = PRE / "lidar_meta.json"
JOB = PRE / "lidar_job.json"
STEP_M = 2.0
_KEYS = ("source_filter", "method", "smooth")
def las_target(params: dict[str, Any]) -> dict[str, Any] | None:
"""이미 있는 라이다 확정값 — 지금 설계(라이다) · 적어 둔 것 · 없으면 None(새로 덧붙임)."""
if design_terrain(params) == "las":
return {key: params[key] for key in _KEYS}
stash = params.get(LAS_STASH_KEY)
if isinstance(stash, dict) and stash.get("source_filter"):
return {key: stash[key] for key in _KEYS if key in stash}
return None
def project_epsg(root: Path) -> int:
"""작업 좌표계 — 설계 노선 · 지형 PRJ 사다리(`resolve_project_crs`) · EPSG 로 못 읽으면 400."""
from pyproj import CRS
from common_util.common_util_crs import identify_epsg, resolve_project_crs
from common_util.common_util_route_geometry import load_design_route
planned = load_design_route(root)
text = resolve_project_crs(root, route_crs_input=planned.crs_input if planned else None)
epsg = identify_epsg(CRS.from_user_input(text), text)
if epsg is None:
raise HTTPException(status_code=400, detail="프로젝트 좌표계를 EPSG 로 읽지 못했습니다.")
return int(epsg)
def line_points(line: list[list[float]], step: float = STEP_M) -> np.ndarray:
"""꺾은선을 `step` m 마다(꼭짓점 포함) 나눈 점."""
xy = np.asarray(line, dtype=np.float64).reshape(-1, 2)
out = [xy[:1]]
for a, b in zip(xy[:-1], xy[1:]):
count = max(1, int(np.ceil(np.hypot(*(b - a)) / step)))
t = np.arange(1, count + 1)[:, None] / count
out.append(a + (b - a) * t)
return np.vstack(out)
def route_coverage(root: Path, selection: dict[str, Any]) -> tuple[bool, float]:
"""저장 노선이 새 라이다 지형에 다 덮이나 — (덮임, 덮인 비율)."""
from B05_Profile.B05_Profile_Router_Replan import _planned_line
from common_util.common_util_surface_sampler import build_surface_sampler
line = _planned_line(root)
if len(line) < 2:
return False, 0.0
sampler = build_surface_sampler(
root / MODELS,
str(selection["source_filter"]),
str(selection["method"]),
bool(selection["smooth"]),
)
_, valid = sampler.sample_xy(line_points(line))
return bool(valid.all()), round(float(np.mean(valid)), 4)
def build_route_lidar(
root: Path, epsg: int, target: dict[str, Any] | None, report: Any
) -> dict[str, Any]:
"""올린 LAS → `lidar_las/` 갈음 → 뿌리에 짓기(있던 라이다 필터 · 없으면 자동) → 덮임 → 메타."""
from config.config_system import (
SURFACE_AUTO_METHODS,
SURFACE_CONFIRM_DEFAULT_METHOD,
SURFACE_CONFIRM_DEFAULT_SMOOTH,
)
las_paths = lidar_upload.stage_las(root / UPLOAD, root / LAS_DIR, epsg, report)
method = str((target or {}).get("method") or SURFACE_CONFIRM_DEFAULT_METHOD)
result = lidar_upload.build_surface(
root,
las_paths,
report,
[str(target["source_filter"])] if target else None,
sorted({*SURFACE_AUTO_METHODS, method}),
)
selection = target or {
"source_filter": result["source_filters"][0],
"method": method,
"smooth": bool(SURFACE_CONFIRM_DEFAULT_SMOOTH),
}
report(96, "노선 덮임 보는 중")
covered, ratio = route_coverage(root, selection)
meta = {
"epsg": int(epsg),
"files": [p.name for p in las_paths],
"selection": selection,
"point_count": result["processed"]["point_count"],
"bounds": result["processed"]["bounds"],
"covered": covered,
"covered_ratio": ratio,
}
atomic_write_json(root / META, meta)
models = [model for model in result["models"] if model.get("model_file_path")]
return {"meta": meta, "models": models}
async def _root_and_params(project_id: UUID) -> tuple[Path, dict[str, Any]]:
from B05_Profile.B05_Profile_Router_Replan import _project_paths
paths = await _project_paths(project_id)
if paths is None:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
async with get_db_pool().acquire() as connection:
params = await get_surface_confirmation_params(connection, str(project_id))
return paths[0], params
@router.post("/{project_id}/route/lidar", response_model=None)
async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict:
"""B 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `GET …/route/lidar`)."""
from common_util import common_util_save_guard as guard
lidar_upload.check_names([upload.filename or "" for upload in files])
root, params = await _root_and_params(project_id)
lock = guard.project_lock(str(project_id))
if lock.locked():
raise HTTPException(status_code=409, detail="저장 중입니다 — 끝난 뒤 다시 올리세요.")
if (_JOBS.get((str(project_id), KIND)) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
target, design = las_target(params), design_terrain(params)
epsg = await asyncio.to_thread(project_epsg, root)
await lidar_upload.receive_uploads(files, root / UPLOAD)
async def work(report: Any) -> None:
async with lock:
got = await asyncio.to_thread(build_route_lidar, root, epsg, target, report)
await lidar_upload.save_models(project_id, got["models"], epsg, keep=True)
if design == "sheet": # 칩 라이다 열림 · 설계 지형은 그대로
async with get_db_pool().acquire() as connection:
await connection.begin()
await stash_las_selection(connection, str(project_id), got["meta"]["selection"])
await connection.commit()
report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개")
return lidar_upload.start_job(_JOBS, _TASKS, project_id, KIND, work, root / JOB, "B05")
def read_meta(root: Path) -> dict[str, Any]:
try:
return json.loads((root / META).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
@router.get("/{project_id}/route/lidar", response_model=None)
async def route_lidar(project_id: UUID) -> dict[str, Any]:
"""올린 라이다 — 진행 · 메타(덮임 · 좌표계 · 파일) · 지금 설계 지형 · 고를 수 있는 지형."""
root, params = await _root_and_params(project_id)
job = _JOBS.get((str(project_id), KIND))
job = dict(job) if job else await asyncio.to_thread(lidar_upload.saved_job, root / JOB)
meta = await asyncio.to_thread(read_meta, root)
picked = meta.get("selection") or {}
dem = root / MODELS / f"dtm_{picked.get('source_filter')}.npz"
ready = bool(picked) and await asyncio.to_thread(dem.is_file)
return {
"status": "success",
**job,
"ready": ready,
"covered": bool(meta.get("covered")),
"covered_ratio": meta.get("covered_ratio"),
"epsg": meta.get("epsg"),
"files": meta.get("files", []),
"point_count": meta.get("point_count"),
"design": design_terrain(params),
"sources": analysis_terrain_sources(params, root / MODELS),
}
@@ -5,7 +5,8 @@
(종단 · 횡단 · 배수 · 관 · 구조물 따라감 ·
계획고 옮김 없음 = 옛 · 새 계획 폴리라인 같음). 노선 · 노드 · 곡선표 = 지금 저장본 그대로(안 씀).
초기값(`initial_snapshot/`) · 추천 저장본 · 예정노선 안 씀(옛 추천 [적용] 길 `as_initial` 대신).
[저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(`common_util_save_guard`) · 같은 지형 = 바꿀 것 없음.
[저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(`common_util_save_guard`) ·
같은 지형 = 바꿀 것 없음(`force` = 같은 지형도 체인 다시 탐 — LAS 갈음 뒤 · PLAN 637-2).
"""
import asyncio
@@ -28,6 +29,8 @@ logger = logging.getLogger(__name__)
class DesignTerrainRequest(BaseModel):
terrain: Literal["las", "sheet"]
#: 같은 지형도 다시 셈 — LAS 갈음 뒤 설계 지형이 라이다인 B(PLAN 637-2)
force: bool = False
@router.post("/{project_id}/route/design-terrain", response_model=None)
@@ -53,7 +56,7 @@ async def switch_route_design_terrain(
pool = get_db_pool()
async with pool.acquire() as connection:
now = design_terrain(await get_surface_confirmation_params(connection, str(project_id)))
if now == body.terrain:
if now == body.terrain and not body.force:
return {"status": "success", "design": now, "changed": False}
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
files = await asyncio.to_thread(
+8 -10
View File
@@ -78,11 +78,11 @@ async def post_plan_project(
async def assigned_plan_routes(
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""우리 회사에 지정된 예정노선 조각 — 확정된 것(또는 이미 연결한 것) · 연결 상태 · 업데이트 ·
조각 이름 · 길이 · 연도(설계 내용 파일 `plan_state` · 업체 줄은 조각 번호만)."""
"""우리 회사에 지정된 예정노선 세트 — 확정된 것(또는 이미 연결한 것) · 연결 상태 · 업데이트 ·
세트 이름 「2027-1」 · 길이 · 연도(설계 내용 파일 `plan_state` · 업체 줄은 세트 열쇠만 ·
옛 조각 id = 든 세트 · PLAN 638-1)."""
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D04_PlanConfirm.D04_PlanConfirm_Link import update_available
from D04_PlanConfirm.D04_PlanConfirm_Router import piece_label
async with (
get_db_pool().acquire() as connection,
@@ -100,18 +100,17 @@ async def assigned_plan_routes(
(session.get("company_id"),),
)
rows = await cursor.fetchall()
pieces: dict[tuple[str, str], dict[str, Any]] = {}
sets: dict[str, list[dict[str, Any]]] = {}
for project_id in {str(r["project_id"]) for r in rows}:
try:
units = (await store.plan_state(project_id))["units"]
sets[project_id] = (await store.plan_state(project_id))["units"]
except HTTPException: # 노선 파일이 틀린 프로젝트 = 건너뜀(목록 전체를 막지 않음)
continue
for unit in units:
pieces[(project_id, unit["id"])] = unit
routes = []
for row in rows:
fingerprint = row.pop("source_fingerprint")
piece = pieces.get((str(row["project_id"]), row["piece_id"]))
piece = store.find_unit(sets.get(str(row["project_id"]), []), row["piece_id"])
row["piece_id"] = piece["id"] if piece else row["piece_id"]
confirmed_at = piece and piece["confirmed_at"]
if confirmed_at is None and row["linked_project_id"] is None:
continue
@@ -121,8 +120,7 @@ async def assigned_plan_routes(
**row,
"year": piece and piece["year"],
"confirmed_at": confirmed_at,
"piece_label": piece
and piece_label(piece["line_name"], piece["from_m"], piece["to_m"]),
"piece_label": piece and piece["id"],
"length_m": piece and piece["length_m"],
"update_available": update_available(route, fingerprint, bool(confirmed_at)),
}
+11 -90
View File
@@ -11,6 +11,7 @@ LAS 좌표계(.prj · LAS 머리 · 없으면 프로젝트 좌표계)가 프로
(높이 표본 격자).
라이다 범위 밖 = 빈칸(격자 `valid_mask`) · 수치지형도로 메우지 않음(결정 A⑥).
이름 검사 · 좌표계 · 옮겨 놓기 · 받기 · 짓기 = 공용 `common_util_lidar_upload`(B05 와 같이 · 637-1).
계산 자리 = ③ 서버 단독.
"""
@@ -18,14 +19,20 @@ from __future__ import annotations
import asyncio
import json
import shutil
from pathlib import Path
from typing import Any, Callable
from uuid import UUID
from fastapi import APIRouter, File, HTTPException, UploadFile
from common_util import common_util_lidar_upload as lidar_upload
from common_util.common_util_json import atomic_write_json
from common_util.common_util_lidar_upload import ( # noqa: F401 — 옛 이름(시험 · 부르는 쪽)
LAS_SUFFIXES,
check_names,
las_epsg,
place_las,
)
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Lidar"])
@@ -34,8 +41,6 @@ UPLOAD = "upload"
LAS_DIR = Path("B03_FileInput") / "input" / "las"
MODELS = Path("B04_PreProcess") / "models"
META = "lidar_meta.json"
LAS_SUFFIXES = (".las", ".laz")
CHUNK = 2_000_000
def lidar_root(root: Path) -> Path:
@@ -56,86 +61,11 @@ def lidar_dem(root: Path) -> Path | None:
return path if path is not None and path.is_file() else None
def check_names(names: list[str]) -> None:
"""LAS · LAZ 하나 이상 · 그 밖은 .prj 만 · 같은 이름 없음."""
suffixes = [Path(name).suffix.lower() for name in names]
if not any(s in LAS_SUFFIXES for s in suffixes):
raise HTTPException(status_code=400, detail="LAS 또는 LAZ 파일을 하나 이상 올리세요.")
if any(s not in (*LAS_SUFFIXES, ".prj") for s in suffixes):
raise HTTPException(status_code=400, detail="LAS · LAZ · PRJ 만 올릴 수 있습니다.")
if len({Path(name).name.casefold() for name in names}) != len(names):
raise HTTPException(status_code=400, detail="같은 이름 파일이 있습니다.")
def las_epsg(path: Path, prj_text: str | None) -> int | None:
"""LAS 좌표계 — .prj 먼저 · 없으면 LAS 머리 · 모르면 None."""
import laspy
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
if prj_text:
return identify_epsg(CRS.from_wkt(prj_text), prj_text)
with laspy.open(path) as reader:
crs = reader.header.parse_crs()
return identify_epsg(crs, crs.to_wkt()) if crs is not None else None
def place_las(src: Path, dst_dir: Path, src_epsg: int | None, epsg: int) -> Path:
"""올린 LAS → 지형 입력 자리 — 좌표계가 같거나 모르면 옮김 · 다르면 x · y 만 옮겨 적음(.las)."""
import laspy
from pyproj import CRS, Transformer
dst_dir.mkdir(parents=True, exist_ok=True)
if src_epsg in (None, int(epsg)):
return Path(shutil.move(src, dst_dir / src.name))
dst = dst_dir / f"{src.stem}.las"
move = Transformer.from_crs(src_epsg, int(epsg), always_xy=True)
with laspy.open(src) as reader:
old = reader.header
header = laspy.LasHeader(point_format=old.point_format, version=old.version)
header.scales = old.scales
x0, y0 = move.transform(old.mins[0], old.mins[1])
header.offsets = [round(x0), round(y0), old.offsets[2]]
header.add_crs(CRS.from_epsg(int(epsg)))
with laspy.open(dst, mode="w", header=header) as writer:
for points in reader.chunk_iterator(CHUNK):
x, y = move.transform(points.x, points.y)
record = laspy.ScaleAwarePointRecord.zeros(len(points), header=header)
record.array[:] = points.array
record.x, record.y, record.z = x, y, points.z
writer.write_points(record)
src.unlink()
return dst
def build_lidar(root: Path, epsg: int, report: Callable[[int, str], None]) -> dict[str, Any]:
"""올린 자리(`upload/`)의 LAS → 입력 자리 갈음 → B04 엔진(지면 필터 · DTM) → 메타."""
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
from config.config_system import SURFACE_AUTO_METHODS
base = lidar_root(root)
upload, las_dir = base / UPLOAD, base / LAS_DIR
prj = next(iter(sorted(upload.glob("*.prj"))), None)
prj_text = prj.read_text(encoding="utf-8", errors="ignore") if prj else None
sources = sorted(p for p in upload.iterdir() if p.suffix.lower() in LAS_SUFFIXES)
if sources: # 새로 올림 = 옛 LAS 갈음
shutil.rmtree(las_dir, ignore_errors=True)
report(5, "LAS 좌표계 맞추는 중")
for path in sources:
place_las(path, las_dir, las_epsg(path, prj_text), epsg)
shutil.rmtree(upload, ignore_errors=True)
las_paths = sorted(p for p in las_dir.glob("*") if p.suffix.lower() in LAS_SUFFIXES)
if not las_paths:
raise FileNotFoundError("올린 LAS 가 없습니다.")
result = run_surface_analysis(
base,
las_paths,
source_filters=None,
methods=list(SURFACE_AUTO_METHODS),
on_progress=lambda percent, _stage, message: report(10 + int(percent * 0.85), message),
maps=False,
)
las_paths = lidar_upload.stage_las(base / UPLOAD, base / LAS_DIR, epsg, report)
result = lidar_upload.build_surface(base, las_paths, report)
prefix = LIDAR.as_posix()
models = [
{**model, "model_file_path": f"{prefix}/{model['model_file_path']}"}
@@ -158,22 +88,13 @@ def build_lidar(root: Path, epsg: int, report: Callable[[int, str], None]) -> di
@router.post("/{project_id}/plan/lidar", response_model=None)
async def upload_plan_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict:
"""D 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `/plan/progress` 의 `lidar`)."""
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from D03_PlanAnalysis import D03_PlanAnalysis_Router as base
check_names([upload.filename or "" for upload in files])
root, epsg, _, _ = await base._plan_ends(project_id) # 예정노선 아님 400
if (base._JOBS.get((str(project_id), "lidar")) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
upload = lidar_root(root) / UPLOAD
shutil.rmtree(upload, ignore_errors=True)
upload.mkdir(parents=True)
try:
for item in files:
await save_upload_stream(item, upload / Path(item.filename or "").name)
except ValueError as error:
shutil.rmtree(upload, ignore_errors=True)
raise HTTPException(status_code=413, detail=str(error)) from error
await lidar_upload.receive_uploads(files, lidar_root(root) / UPLOAD)
async def work(report: Any) -> None:
got = await asyncio.to_thread(build_lidar, root, epsg, report)
+172 -9
View File
@@ -7,12 +7,18 @@
다시 셈 → 파일(원자 쓰기) · 연도 상태 줄(`plan_route_years` 지문 · 확정) · 사업연도 끝
길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터.
조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`).
조각 = 선의 마크 사이 구간(구역 × 갈래) · 조각마다 세트 번호 `set`(요구 547 · 548 · PLAN 638-1) —
세트 열쇠 「연도-번호」(2027-1) = 같은 노선 · 업체 지정 단위(`plan_route_assignees.piece_id`) ·
같은 선 이웃 조각이 같은 세트면 [저장] · 읽기 때 하나로(앞 조각 id · 사이 마크 걷음) ·
`set` 없는 옛 조각 = 그 해 선 차례 · 거리 차례로 최댓값 + 1 ·
세트마다 덩이 수 · 갈래 = `derived.sets`.
지점(`points` · 긋기에 붙는 점) = 같은 파일 · [저장] 몸에 실림(빈 값 = 그대로) ·
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2) ·
[저장] 몸 `site` 가 바뀌었으면 같은 칸을 고침(594-3 · 등록과 한 칸).
지점 · 선 이름(20 자 · 파일 이름 못 쓰는 글자 막음) · 선 노드 기록 = 파일에 있을 때만(594-1).
계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈.
⚠ 짝 = `normalize`(세트 번호 · 합침) ↔ TS `logic/D03_PlanAnalysis_Group.ts` —
거울 시험 `test_638_sets`(사례 `resources/tester/fixtures/d03_638_set_cases.json`).
"""
from __future__ import annotations
@@ -85,6 +91,7 @@ class Piece(BaseModel):
from_m: float
to_m: float
year: int
set: int | None = Field(default=None, ge=1) # 세트 번호(638-1) · 빈 값 = 그 해 새 번호
class Network(BaseModel):
@@ -168,11 +175,137 @@ def _check(ok: bool, message: str) -> None:
raise NetworkError(message)
def set_key(year: int, no: int) -> str:
return f"{int(year)}-{int(no)}"
def _numbered(network: dict[str, Any]) -> list[dict[str, Any]]:
"""조각 사본 — (선 차례, from_m) 차례 · `set` 없는 조각 = 그 해 최댓값 + 1(차례대로)."""
order = {line["id"]: i for i, line in enumerate(network["lines"])}
pieces = sorted(
(dict(p) for p in network["pieces"]),
key=lambda p: (order.get(p["line"], len(order)), p["from_m"]),
)
top: dict[int, int] = {}
for piece in pieces:
if piece.get("set"):
top[piece["year"]] = max(top.get(piece["year"], 0), int(piece["set"]))
for piece in pieces:
if not piece.get("set"):
top[piece["year"]] = piece["set"] = top.get(piece["year"], 0) + 1
return pieces
def normalize(network: dict[str, Any]) -> dict[str, Any]:
"""세트 번호 매김 + 같은 선 이웃 같은 세트 합침(앞 조각 id · 사이 마크 걷음) — 사본.
⚠ 짝 = TS `logic/D03_PlanAnalysis_Group.ts` — 거울 시험 `test_638_sets`."""
marks = [dict(m) for m in network["marks"]]
pieces: list[dict[str, Any]] = []
for piece in _numbered(network):
prev = pieces[-1] if pieces else None
same = (
prev is not None
and prev["line"] == piece["line"]
and (prev["year"], prev["set"]) == (piece["year"], piece["set"])
and abs(prev["to_m"] - piece["from_m"]) <= PIECE_TOL_M
)
cut = same and next(
(
m
for m in marks
if m["line"] == piece["line"] and abs(m["at_m"] - piece["from_m"]) <= PIECE_TOL_M
),
None,
)
if cut:
marks.remove(cut)
prev["to_m"] = piece["to_m"]
continue
pieces.append(piece)
return {**network, "marks": marks, "pieces": pieces}
def _touch(a: Any, b: Any) -> bool:
return draw.segment_length(a, b) <= PIECE_TOL_M
def _set_shape(items: list[dict[str, Any]]) -> tuple[int, bool]:
"""(덩이 수, 갈래 있음) — 조각 끝이 다른 조각 꼭짓점에 닿으면 이어짐 · 한 점에 3 이상 = 갈래."""
root = list(range(len(items)))
def find(i: int) -> int:
while root[i] != i:
i = root[i]
return i
branch = False
for i, piece in enumerate(items):
for end in (piece["planned"][0], piece["planned"][-1]):
degree = 0
for j, other in enumerate(items):
line = other["planned"]
hits = sum(_touch(end, p) for p in (line[0], line[-1]))
hits += 2 * any(_touch(end, p) for p in line[1:-1])
degree += hits
if hits and j != i:
root[find(i)] = find(j)
branch = branch or degree >= 3
return len({find(i) for i in range(len(items))}), branch
def join_set(items: list[dict[str, Any]]) -> list[list[float]] | None:
"""세트 조각을 끝끼리 이은 한 꺾은선(첫 조각 방향) · 못 이으면 None."""
chain = [list(p) for p in items[0]["planned"]]
rest = list(items[1:])
while rest:
for piece in rest:
line = [list(p) for p in piece["planned"]]
if _touch(chain[-1], line[0]):
chain += line[1:]
elif _touch(chain[-1], line[-1]):
chain += line[::-1][1:]
elif _touch(chain[0], line[-1]):
chain = line[:-1] + chain
elif _touch(chain[0], line[0]):
chain = line[::-1][:-1] + chain
else:
continue
rest.remove(piece)
break
else:
return None
return chain
def sets_of(pieces: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""세트마다 {key, year, no, parts, branch, length_m, pieces} — 연도 · 번호 차례."""
groups: dict[tuple[int, int], list[dict[str, Any]]] = {}
for piece in pieces:
groups.setdefault((piece["year"], piece["set"]), []).append(piece)
out = []
for (year, no), items in sorted(groups.items()):
parts, branch = _set_shape(items)
out.append(
{
"key": set_key(year, no),
"year": year,
"no": no,
"parts": parts,
"branch": branch,
"length_m": sum(p["length_m"] for p in items),
"pieces": [p["id"] for p in items],
}
)
return out
def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, Any]:
"""선 · 마크 · 조각 → 선 길이 · 측점 · 조각(경계 = 마크 · 꺾은선 · 측점) ·
틀리면 `NetworkError`.
"""선 · 마크 · 조각 → 선 길이 · 측점 · 조각(경계 = 마크 · 꺾은선 · 측점) · 세트 ·
틀리면 `NetworkError` · 세트 번호 · 합침은 `normalize` 뒤 값으로.
`year_from` = 사업연도 시작(그 앞 연도 조각 거절 · None = 안 봄)."""
network = normalize(network)
lines: dict[str, list] = {}
for line in network["lines"]:
vertices = line["vertices"]
@@ -239,6 +372,8 @@ def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, A
"id": piece["id"],
"line": key,
"year": int(piece["year"]),
"set": int(piece["set"]),
"set_key": set_key(piece["year"], piece["set"]),
"from_m": a,
"to_m": b,
"length_m": b - a,
@@ -255,6 +390,7 @@ def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, A
{"id": key, "length_m": lengths[key], "stations": stations[key]} for key in lines
],
"pieces": pieces,
"sets": sets_of(pieces),
}
@@ -326,7 +462,7 @@ def base_view(plan: dict[str, Any], site: dict[str, Any] | None) -> dict[str, An
def _view(plan: dict[str, Any], project: dict[str, Any]) -> dict[str, Any]:
network = {key: plan[key] for key in ("epsg", "lines", "marks", "pieces")}
network = normalize({key: plan[key] for key in ("epsg", "lines", "marks", "pieces")})
return {
"status": "success",
"base": base_view(plan, project["site"]),
@@ -364,21 +500,42 @@ async def get_plan_network(project_id: UUID) -> dict[str, Any]:
raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error
def old_keys(plan: dict[str, Any]) -> dict[str, str]:
"""저장 전 파일의 조각 id → 세트 열쇠(옛 업체 줄 `p1` … 풀어 봄 · 합쳐질 뒤 조각 포함)."""
return {p["id"]: set_key(p["year"], p["set"]) for p in _numbered(plan)}
async def _store_status(
connection: Any, project_id: UUID, pieces: list[dict[str, Any]], years: dict[int, Any]
connection: Any,
project_id: UUID,
keys: set[str],
years: dict[int, Any],
before: dict[str, str] | None = None,
) -> None:
"""연도 상태 줄 — 있는 연도만(지문이 바뀐 연도만 확정 풂) · 없는 연도 줄 지움 · 사업연도 끝."""
"""업체 줄 열쇠 · 연도 상태 줄 — 지정된 세트가 사라지면 409 · 옛 조각 열쇠 = 그 세트 열쇠로 ·
있는 연도만(지문이 바뀐 연도만 확정 풂) · 없는 연도 줄 지움 · 사업연도 끝."""
pid = str(project_id)
before = before or {}
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT DISTINCT piece_id FROM plan_route_assignees"
" WHERE project_id = %s AND piece_id IS NOT NULL",
(pid,),
)
lost = sorted({r[0] for r in await cursor.fetchall()} - {p["id"] for p in pieces})
held = {r[0] for r in await cursor.fetchall()}
moved = {k: before[k] for k in held if k not in keys and k in before}
lost = sorted({moved.get(k, k) for k in held} - keys)
if lost:
raise HTTPException(
status_code=409, detail=f"업체가 지정된 조각을 지울 수 없습니다: {lost}"
status_code=409,
detail=f"업체가 지정된 세트가 없어집니다: {', '.join(lost)}"
" — 결과 확인(D04)에서 업체를 먼저 빼세요.",
)
for old, new in moved.items():
await cursor.execute(
"UPDATE plan_route_assignees SET piece_id = %s"
" WHERE project_id = %s AND piece_id = %s",
(new, pid, old),
)
for year, row in years.items():
# confirmed_at 을 fingerprint 보다 먼저 셈
@@ -447,6 +604,8 @@ async def put_plan_network(
if plan["epsg"] is not None and plan["epsg"] != network["epsg"]:
raise HTTPException(status_code=400, detail="좌표계가 저장된 노선과 다릅니다.")
project = await project_info(project_id)
before = old_keys(plan)
network = normalize(network)
try:
derived = derive(network, project["year_start"])
except NetworkError as error:
@@ -460,7 +619,11 @@ async def put_plan_network(
await connection.begin()
try:
await _store_status(
connection, project_id, derived["pieces"], year_rows(derived["pieces"])
connection,
project_id,
{s["key"] for s in derived["sets"]},
year_rows(derived["pieces"]),
before,
)
if site:
await _store_site(connection, project_id, site)
+7 -66
View File
@@ -35,9 +35,9 @@ from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util import common_util_contour_tiles as contour_tiles
from common_util import common_util_lidar_upload as lidar_upload
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
@@ -118,7 +118,7 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]:
raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
IDLE = lidar_upload.IDLE
def _job_path(root: Path, kind: str) -> Path:
@@ -126,81 +126,22 @@ def _job_path(root: Path, kind: str) -> Path:
def _note_job(path: Path | None, job: dict[str, Any]) -> None:
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, job)
except OSError as error:
logger.warning("D 진행 기록 실패: %s", error)
lidar_upload.note_job(path, job)
def _saved_job(root: Path, kind: str) -> dict[str, Any]:
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
message 없음)."""
try:
saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8"))
except (OSError, ValueError):
return dict(IDLE)
if not (isinstance(saved, dict) and saved.get("running")):
return dict(IDLE)
percent = min(99, int(saved.get("percent") or 0))
return {**IDLE, "percent": percent, "interrupted": True}
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(공용 `saved_job`)."""
return lidar_upload.saved_job(_job_path(root, kind))
def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False}
_JOBS[key] = job
path = _job_path(root, kind) if root is not None else None
_note_job(path, job)
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
_note_job(path, job)
async def run() -> None:
try:
await work(report)
job.update(percent=100)
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("D %s 실패: project_id=%s", kind, project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
_note_job(path, job)
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", **job}
return lidar_upload.start_job(_JOBS, _TASKS, project_id, kind, work, path, "D")
async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int) -> None:
"""지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로)."""
from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
existing = await _existing_models(connection, project_id)
for model in models:
await _upsert_surface_model(
connection,
existing,
project_id=project_id,
model=model,
source_file_id=None,
processed_cloud_id=None,
crs_epsg=epsg,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
await lidar_upload.save_models(project_id, models, epsg)
@router.get("/{project_id}/plan/terrain/extent", response_model=None)
+63 -17
View File
@@ -1,11 +1,13 @@
"""D 예정노선 설계 내용 한 파일 — `<프로젝트>/D03_PlanAnalysis/plan_network.json`
(요구 452 · 453 · PLAN 590-1).
- 꼴 = `{version, epsg, points: [{x, y}], lines, marks, pieces: [{id, line, from_m, to_m, year}]}` —
좌표계 = 머리 한 곳 · 연도 = 조각의 `year` 뿐(빈 연도 줄 없음) · 길이 · 측점 · 조각 좌표 ·
연도 지문은 읽을 때 `plan_state` 가 셈(저장 안 함).
- 꼴 = `{version: 2, epsg, points: [{x, y}], lines, marks,
pieces: [{id, line, from_m, to_m, year, set}]}` — 좌표계 = 머리 한 곳 · 연도 = 조각의 `year` 뿐 ·
세트 = 조각의 `set`(열쇠 「연도-번호」 · PLAN 638-1 · 판 1 = `set` 없음 → 읽을 때 번호) ·
길이 · 측점 · 조각 좌표 · 세트 덩이 · 연도 지문은 읽을 때 `plan_state` 가 셈(저장 안 함).
- DB 에 남는 것 = 등록 칸 · 연도 상태(`plan_route_years` 의 year · fingerprint · confirmed_at) ·
업체 줄(`plan_route_assignees` 조각 번호 · 업체 · 메일 시각 · 연결 — 연도는 이 파일에서).
업체 줄(`plan_route_assignees.piece_id` = 세트 열쇠(옛 줄 = 조각 id → `find_unit` 이 그 세트로) ·
업체 · 메일 시각 · 연결 — 연도는 이 파일에서).
- 파일이 없으면 빈 설계(새 프로젝트) · 파일은 첫 쓰기 때 생김(옛 DB 칸 옮김 = 590-3 에서 끝남).
- 쓰기 = `project_lock(project_id)` 안에서 `save_plan`(같은 폴더 임시 파일 → 바꿔치기 · 반쯤 쓴
파일이 안 보임) · 읽기는 잠금 없음.
@@ -30,7 +32,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names
FILE = Path("D03_PlanAnalysis") / "plan_network.json"
VERSION = 1
VERSION = 2 # 2 = 조각 `set`(옛 서버가 덮어 세트를 지우지 않게 · 638-1)
KEYS = ("epsg", "points", "lines", "marks", "pieces")
@@ -101,16 +103,59 @@ async def project_row(project_id: UUID | str) -> dict[str, Any]:
return await _project(cursor, project_id, plan_only=False)
def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]:
"""조각마다 {id · line · line_name · year · epsg · from_m · to_m · length_m · planned ·
stations}
— 연도 · 선 차례 · 선 위 거리 차례 · 틀리면 `NetworkError`."""
pieces = network.derive(plan)["pieces"] if plan["lines"] else []
def plan_parts(plan: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""(조각, 세트 단위) — 틀리면 `NetworkError`.
조각 = {id · line · line_name · year · set · set_key · epsg · from_m · to_m · length_m ·
planned · stations} · 연도 · 선 차례 · 선 위 거리 차례.
세트 단위 = {id(「2027-1」) · set · year · line · line_name(첫 조각) · epsg · from_m · to_m ·
length_m · planned(이은 한 줄 · 덩이 2 이상 · 갈래면 빈 것) · parts · branch ·
pieces(조각 id)} —
여러 조각 세트의 to_m = from_m + 길이."""
if not plan["lines"]:
return [], []
derived = network.derive(plan)
pieces = derived["pieces"]
names = dict(zip((line["id"] for line in plan["lines"]), line_names(plan["lines"])))
for piece in pieces:
piece.update(line_name=names.get(piece["line"], "갈래"), epsg=plan["epsg"])
pieces.sort(key=lambda p: p["year"]) # 같은 연도 안 = derive 차례(선 · 거리) 그대로
return pieces
by_id = {p["id"]: p for p in pieces}
units = []
for item in derived["sets"]:
parts = [by_id[i] for i in item["pieces"]]
first = parts[0]
whole = item["parts"] == 1 and not item["branch"]
units.append(
{
"id": item["key"],
"set": item["no"],
"year": item["year"],
"line": first["line"],
"line_name": first["line_name"],
"epsg": plan["epsg"],
"from_m": first["from_m"],
"to_m": first["to_m"] if len(parts) == 1 else first["from_m"] + item["length_m"],
"length_m": item["length_m"],
"planned": (network.join_set(parts) or []) if whole else [],
"parts": item["parts"],
"branch": item["branch"],
"pieces": item["pieces"],
}
)
return pieces, units
def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]:
"""조각만(`plan_parts` 앞 값)."""
return plan_parts(plan)[0]
def find_unit(units: list[dict[str, Any]], key: str | None, field: str = "id") -> Any:
"""업체 줄 열쇠 → 세트 단위 — 세트 열쇠 · 옛 조각 id(그 조각이 든 세트) · 없으면 None."""
return next((u for u in units if u[field] == key), None) or next(
(u for u in units if key in u["pieces"]), None
)
async def year_status(project_id: UUID | str) -> dict[int, dict[str, Any]]:
@@ -127,19 +172,20 @@ async def year_status(project_id: UUID | str) -> dict[int, dict[str, Any]]:
async def plan_state(project_id: UUID | str) -> dict[str, Any]:
"""{root, plan, units, years} — `years` = `year_rows` + `confirmed_at`(지문이 같을 때만) ·
단위마다 `confirmed_at` · 노선이 틀리면 409."""
"""{root, plan, pieces, units, years} — `units` = 세트 단위(`plan_parts`) ·
`years` = `year_rows` + `confirmed_at`(지문이 같을 때만) · 단위 · 조각마다 `confirmed_at` ·
노선이 틀리면 409."""
root, plan = await load_plan(project_id)
try:
units = plan_units(plan)
pieces, units = plan_parts(plan)
except network.NetworkError as error:
raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error
years = network.year_rows(units)
years = network.year_rows(pieces)
status = await year_status(project_id)
for year, row in years.items():
saved = status.get(year)
same = saved is not None and saved["fingerprint"] == row["fingerprint"]
row["confirmed_at"] = saved["confirmed_at"] if same else None
for unit in units:
for unit in (*pieces, *units):
unit["confirmed_at"] = years[unit["year"]]["confirmed_at"]
return {"root": root, "plan": plan, "units": units, "years": years}
return {"root": root, "plan": plan, "pieces": pieces, "units": units, "years": years}
+25 -11
View File
@@ -4,12 +4,13 @@
new = 임도 프로젝트 새로(B02 저장소 함수) · existing = 고른 우리 회사 임도 프로젝트
- `GET /api/projects/{id}/plan-source` — 임도 프로젝트가 이어 받은 예정노선 · 업데이트 있음
연결 = 지정 행의 조각 shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에
연결 = 지정 행의 세트(「2027-1」 · PLAN 638-1) 구간을 이은 한 줄 shp 한 벌(D04 내보내기 함수)을
B03 입력 `input/shp/` 에
원래 이름으로 + `input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길.
입력이 다 차면 B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림.
연결 시점 복사 · 원본이 바뀌면 알림만(내용 지문 비교 · 자동 반영 없음).
조각 노선 · 연도 · 확정 = 설계 내용 파일(`D03_PlanAnalysis_Store.plan_state`) —
업체 줄은 조각 번호만.
세트 노선 · 연도 · 확정 = 설계 내용 파일(`D03_PlanAnalysis_Store.plan_state`) —
업체 줄은 세트 열쇠만(옛 조각 id = 그 조각이 든 세트) · 덩이 2 이상 · 갈래 세트는 연결 거절.
"""
from __future__ import annotations
@@ -102,20 +103,26 @@ async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None:
async def piece_route(project_id: str, piece_id: str | None) -> dict[str, Any]:
"""그 조각(파일) — {year, epsg, route: {planned, 누가 · 길이}, confirmed_at} ·
조각이 없으면 노선 · 연도 · 확정 None."""
"""그 세트(파일 · 옛 조각 id = 든 세트) — {set_key, year, epsg, route: {planned(이은 한 줄),
누가 · 길이}, parts, branch, confirmed_at} · 세트가 없으면 노선 · 연도 · 확정 None."""
state = await store.plan_state(project_id)
unit = next((u for u in state["units"] if u["id"] == piece_id), None)
unit = store.find_unit(state["units"], piece_id)
if unit is None:
return {
"set_key": None,
"year": None,
"epsg": state["plan"]["epsg"],
"route": None,
"parts": 0,
"branch": False,
"confirmed_at": None,
}
return {
"set_key": unit["id"],
"year": unit["year"],
"epsg": unit["epsg"],
"parts": unit["parts"],
"branch": unit["branch"],
"route": {
"planned": unit["planned"],
"start_chainage_m": unit["from_m"],
@@ -147,7 +154,7 @@ async def load_road_project(project_id: str) -> dict[str, Any] | None:
async def record_link(project_id: str, assignee: dict[str, Any], fingerprint: str | None) -> None:
"""원본 칸 · 연결 표시 — 한 임도 프로젝트는 한 조각에만 이어짐(옛 연결은 끊음)."""
"""원본 칸 · 연결 표시 — 한 임도 프로젝트는 한 세트에만 이어짐(옛 연결은 끊음)."""
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await connection.begin()
try:
@@ -172,7 +179,7 @@ async def record_link(project_id: str, assignee: dict[str, Any], fingerprint: st
def year_files(assignee: dict[str, Any]) -> dict[str, bytes]:
"""그 조각 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트."""
"""그 세트 한 줄 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트."""
body = _parsed(assignee["route"]) or {}
route = YearRoute(
year=int(assignee["year"]),
@@ -296,12 +303,19 @@ async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -
async def link_plan_route(
assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any] | JSONResponse:
"""지정받은 조각 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
"""지정받은 세트 노선(이은 한 줄)을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
assignee = await load_assignee(assignee_id)
if not assignee:
raise HTTPException(status_code=404, detail="지정 업체 행을 찾을 수 없습니다.")
if assignee["company_id"] is None or assignee["company_id"] != session.get("company_id"):
raise HTTPException(status_code=403, detail="이 예정노선을 지정받은 회사가 아닙니다.")
if assignee["parts"] > 1 or assignee["branch"]:
why = f"{assignee['parts']} 덩이로 끊김" if assignee["parts"] > 1 else "갈래 있음"
raise HTTPException(
status_code=409,
detail=f"세트 {assignee['set_key']} 가 한 줄이 아닙니다({why})"
" — 산림청이 예정노선에서 이어야 연결할 수 있습니다.",
)
if not assignee["confirmed_at"] or route_fingerprint(assignee["route"]) is None:
raise HTTPException(status_code=409, detail="확정된 노선이 없습니다.")
if payload.mode == "existing":
@@ -333,7 +347,7 @@ async def link_plan_route(
"status": "success",
"project_id": project_id,
"year": assignee["year"],
"piece_id": assignee.get("piece_id"),
"piece_id": assignee["set_key"] or assignee.get("piece_id"),
**result,
}
@@ -360,7 +374,7 @@ async def plan_source(project_id: UUID) -> dict[str, Any]:
"assignee_id": row["assignee_id"],
"plan_name": row["plan_name"],
"year": row["year"],
"piece_id": row.get("piece_id"),
"piece_id": row["set_key"] or row.get("piece_id"),
"update_available": update_available(
row["route"], row["source_fingerprint"], row["confirmed_at"] is not None
),
@@ -84,9 +84,9 @@ async def apply_assignee_changes(
)
for row in updates:
await cursor.execute(
"UPDATE plan_route_assignees SET company_name = %s, email = %s"
" WHERE id = %s AND project_id = %s",
(row["company_name"], row["email"], row["id"], project_id),
"UPDATE plan_route_assignees SET piece_id = %s, company_name = %s, email = %s"
" WHERE id = %s AND project_id = %s", # 옛 조각 id → 세트 열쇠(638-1)
(row["piece_id"], row["company_name"], row["email"], row["id"], project_id),
)
for row in inserts:
await cursor.execute(
+72 -29
View File
@@ -1,14 +1,16 @@
"""D 예정노선 확정 · 업체 지정 API (요구 345 · 391 · 452 · PLAN 531-1 · 531-2 · 563-2 · 590-1).
- `GET /api/projects/{id}/plan/assignees` — 조각별 지정 업체(지정 회사 = 자기 행만)
- `PUT /api/projects/{id}/plan/assignees` — 조각별 지정 업체 저장(등록 회사 또는 업체명 + 메일)
- `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 조각 zip → 메일 첨부 → 발송 시각
- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 · 조각 요약(지정 회사 = 자기 조각 · 행만)
- `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 조각 + 전체 · 공유 = 자기 조각)
- `GET /api/projects/{id}/plan/assignees` — 세트별 지정 업체(지정 회사 = 자기 행만)
- `PUT /api/projects/{id}/plan/assignees` — 세트별 지정 업체 저장(등록 회사 또는 업체명 + 메일)
- `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 세트 zip → 메일 첨부 → 발송 시각
- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 · 세트 요약(지정 회사 = 자기 세트 · 행만)
- `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 세트 + 전체 · 공유 = 자기 세트)
단위 = 조각(설계 내용 파일 `D03_PlanAnalysis_Store` · 구역 × 갈래 · 요구 391). 확정은 연도 단위
(`plan_route_years.confirmed_at` · 지문이 파일과 같을 때만). 업체 줄 = 조각 번호 ·
연도는 파일 조각에서.
단위 = 세트 「2027-1」(설계 내용 파일 `D03_PlanAnalysis_Store.plan_parts` · 요구 547 · 548 ·
PLAN 638-1 · 칸 이름 `piece_id` = 세트 열쇠 그대로). 확정은 연도 단위
(`plan_route_years.confirmed_at` · 지문이 파일과 같을 때만 · 덩이 2 이상 · 갈래 세트가 든 연도는
막음). 업체 줄 = 세트 열쇠(옛 조각 id = 읽을 때 그 세트로 · 다음 [저장] 때 세트 열쇠로) ·
연도는 파일에서.
지정 · 확정은 산림청(시스템 관리자 또는 메일 *.go.kr)만. 지정받은 회사는 공용 접근 판정이
GET 만 통과시킴. 계산 자리 ③ 서버 단독 — 내보내기 파일은 파일 노선을 서버가 다시 센 것으로만.
@@ -73,7 +75,7 @@ def _require_forest(session: dict[str, Any]) -> None:
def _key(row: dict[str, Any]) -> tuple:
"""같은 업체 판정 — 같은 조각에서 등록 회사 = id · 수기 = 메일(대소문자 무시)."""
"""같은 업체 판정 — 같은 세트에서 등록 회사 = id · 수기 = 메일(대소문자 무시)."""
if row.get("company_id") is not None:
return (row["piece_id"], "c", row["company_id"])
return (row["piece_id"], "e", (row.get("email") or "").strip().lower())
@@ -127,10 +129,14 @@ async def _plan_project(project_id: UUID) -> dict[str, Any]:
def route_units(pieces: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""표 한 줄 단위 = 파일 조각(`store.plan_units` · 연도 차례)."""
"""표 한 줄 단위 = 세트(`store.plan_parts` 세트 단위 · 연도 · 번호 차례)."""
return [
{
"piece_id": p["id"],
"set": p.get("set"),
"parts": p.get("parts", 1),
"branch": p.get("branch", False),
"pieces": p.get("pieces", [p["id"]]),
"line": p["line"],
"line_name": p.get("line_name"),
"year": int(p["year"]),
@@ -148,25 +154,43 @@ def route_units(pieces: list[dict[str, Any]]) -> list[dict[str, Any]]:
def with_years(
assignees: list[dict[str, Any]], units: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""업체 줄에 연도 = 그 조각의 파일 연도(조각이 없으면 None) — 업체 줄 `year` 칸은 안 봄."""
years = {u["piece_id"]: u["year"] for u in units}
return [{**a, "year": years.get(a.get("piece_id"))} for a in assignees]
"""업체 줄 열쇠 = 그 세트 열쇠(옛 조각 id 풀어 봄) · 연도 = 그 세트의 파일 연도(없으면 None) —
업체 줄 `year` 칸은 안 봄."""
out = []
for a in assignees:
unit = unit_of(a, units)
key = unit["piece_id"] if unit else a.get("piece_id")
out.append({**a, "piece_id": key, "year": unit and unit["year"]})
return out
def unit_key(unit: dict[str, Any]) -> str | int:
"""내보내기 묶음 열쇠 — 조각 id."""
"""내보내기 묶음 열쇠 — 세트 열쇠."""
return unit["piece_id"]
def unit_label(unit: dict[str, Any]) -> str:
"""zip 폴더 · 첨부 이름 — 「2026_갈래1_p3」."""
"""zip 폴더 · 첨부 이름 — 「2027-1_갈래1」."""
line = (unit["line_name"] or unit["line"]).replace(" ", "")
return f"{unit['year']}_{line}_{unit['piece_id']}"
return f"{unit['piece_id']}_{line}"
def unit_of(assignee: dict[str, Any], units: list[dict[str, Any]]) -> dict[str, Any] | None:
"""업체 행의 단위 = 그 조각."""
return next((u for u in units if u["piece_id"] == assignee.get("piece_id")), None)
"""업체 행의 단위 = 그 세트(옛 조각 id = 그 조각이 든 세트)."""
return store.find_unit(units, assignee.get("piece_id"), "piece_id")
def broken_sets(units: list[dict[str, Any]], years: list[int]) -> list[str]:
"""확정 막을 세트 까닭 — 「2027-1 2 덩이」 · 「2027-2 갈래」."""
out = []
for unit in units:
if unit["year"] not in years:
continue
if unit["parts"] > 1:
out.append(f"{unit['piece_id']} {unit['parts']} 덩이")
if unit["branch"]:
out.append(f"{unit['piece_id']} 갈래")
return out
def _saved(unit: dict[str, Any]) -> bool:
@@ -220,8 +244,10 @@ async def put_plan_assignees(
_, units = await _units(project_id)
piece_years = {u["piece_id"]: u["year"] for u in units}
for item in request.assignees:
unit = store.find_unit(units, item.piece_id, "piece_id") # 옛 조각 id = 든 세트
item.piece_id = unit["piece_id"] if unit else item.piece_id
if item.piece_id not in piece_years:
raise HTTPException(status_code=400, detail=f"조각이 없습니다: {item.piece_id}")
raise HTTPException(status_code=400, detail=f"세트가 없습니다: {item.piece_id}")
years = {piece_years[a.piece_id] for a in request.assignees} | set(request.years or [])
unknown = sorted(years - set(piece_years.values()))
if unknown:
@@ -267,12 +293,20 @@ def _result(assignee: dict[str, Any], state: str) -> dict[str, Any]:
async def finalize_plan(
project_id: UUID, request: FinalizeRequest, session: dict = Depends(verify_session)
) -> dict[str, Any]:
"""연도 노선 확정 → 지정 업체마다 그 조각 zip 을 첨부해 메일 → 보낸 곳에 발송 시각."""
"""연도 노선 확정 → 지정 업체마다 그 세트 zip 을 첨부해 메일 → 보낸 곳에 발송 시각."""
_require_forest(session)
project = await _plan_project(project_id)
rows, units = await _units(project_id)
routes = unit_routes(units)
saved = {r.year for r in routes}
asked = set(request.years) if request.years else {u["year"] for u in units}
broken = broken_sets(units, sorted(asked))
if broken:
raise HTTPException(
status_code=409,
detail=f"끊기거나 갈래가 있는 세트입니다: {', '.join(broken)}"
" — 예정노선에서 한 줄로 이어 오세요.",
)
years = sorted(set(request.years) if request.years else saved)
missing = [y for y in years if y not in saved]
if not years or missing:
@@ -350,8 +384,19 @@ def year_summary(row: dict[str, Any]) -> dict[str, Any]:
def unit_summary(unit: dict[str, Any]) -> dict[str, Any]:
"""표 한 줄(조각) — label = 「본선 0+0~12+10」."""
out = {key: unit[key] for key in ("piece_id", "line", "line_name", "year", "length_m")}
"""표 한 줄(세트) — label = 「2027-1」."""
fields = (
"piece_id",
"set",
"parts",
"branch",
"pieces",
"line",
"line_name",
"year",
"length_m",
)
out = {key: unit[key] for key in fields}
out.update(saved=_saved(unit), confirmed_at=None, label=None)
if unit["confirmed_at"]:
out["confirmed_at"] = utc_text(unit["confirmed_at"])
@@ -364,9 +409,7 @@ def unit_summary(unit: dict[str, Any]) -> dict[str, Any]:
end=_end_point(unit["planned"][-1], epsg),
)
if unit["piece_id"]:
out["label"] = piece_label(
unit["line_name"] or unit["line"], unit["start_chainage_m"], unit["end_chainage_m"]
)
out["label"] = unit["piece_id"]
return out
@@ -379,7 +422,7 @@ def piece_label(line_name: str, from_m: float, to_m: float) -> str:
async def get_plan_summary(
project_id: UUID, session: dict = Depends(verify_session)
) -> dict[str, Any]:
"""`years` = 연도 줄(확정 고르기) · `pieces` = 표 한 줄 단위(조각)."""
"""`years` = 연도 줄(확정 고르기) · `pieces` = 표 한 줄 단위(세트)."""
project = await _plan_project(project_id)
rows, units = await _units(project_id)
units, assignees = visible_to(session, units, await _assignees(project_id, units))
@@ -395,7 +438,7 @@ async def get_plan_summary(
def export_zip(bundle: Any, units: list[dict[str, Any]], with_all: bool) -> bytes:
"""단위마다 `<연도>_<선>_<조각>/` · 전체 `전체/` — 좌표표 이름이 같아 폴더로."""
"""단위마다 `<세트>_<선>/` · 전체 `전체/` — 좌표표 이름이 같아 폴더로."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for unit in units:
@@ -409,7 +452,7 @@ def export_zip(bundle: Any, units: list[dict[str, Any]], with_all: bool) -> byte
@router.get("/{project_id}/plan/export", response_model=None)
async def export_plan(project_id: UUID, session: dict = Depends(verify_session)) -> Response:
"""산림청 = 저장된 전 조각 + 전체 · 지정 회사 = 확정된 자기 조각만(전체 없음)."""
"""산림청 = 저장된 전 세트 + 전체 · 지정 회사 = 확정된 자기 세트만(전체 없음)."""
project = await _plan_project(project_id)
_, units = await _units(project_id)
routes = unit_routes(units)
@@ -421,7 +464,7 @@ async def export_plan(project_id: UUID, session: dict = Depends(verify_session))
own, _ = visible_to(session, picked, await _assignees(project_id, units))
picked = [u for u in own if u["confirmed_at"]]
if not picked:
raise HTTPException(status_code=403, detail="받을 수 있는 조각이 없습니다.")
raise HTTPException(status_code=403, detail="받을 수 있는 세트가 없습니다.")
first = float(project.get("route_start_m") or 0.0)
bundle = await asyncio.to_thread(
build_export, project["name"], project["region"] or "", routes, first
+265
View File
@@ -0,0 +1,265 @@
"""LAS 나중 올리기 공용 — 예정노선(D03) · B05 가 같이 씀(PLAN 637-1 · 요구 546).
- 이름 검사 `check_names` · LAS 좌표계 `las_epsg` · 옮겨 놓기 `place_las`
(좌표계 다르면 x · y 옮겨 적음)
- 받기 고리 `receive_uploads` · 입력 자리 갈음 `stage_las` · 짓기 `build_surface`
(B04 엔진 `run_surface_analysis(maps=False)` — 지도 · GIS 받기 · 도엽 서피스 안 함)
- 뒤 일 `start_job` · 진행 기록 `note_job` · `saved_job`(서버 다시 켜짐 = 「끊김」)
- DB `save_models` — `surface_models` 넣기/고치기만
(다른 행 안 지움 · `save_surface_analysis_to_db` 안 씀)
부르는 쪽 = `D03_PlanAnalysis_Lidar`(자리 `B04_PreProcess/lidar/`) ·
`B05_Profile_Router_Lidar`(프로젝트 뿌리).
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
from pathlib import Path
from typing import Any, Callable
from uuid import UUID
from fastapi import HTTPException, UploadFile
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
LAS_SUFFIXES = (".las", ".laz")
CHUNK = 2_000_000
Report = Callable[[int, str], None]
def check_names(names: list[str]) -> None:
"""LAS · LAZ 하나 이상 · 그 밖은 .prj 만 · 같은 이름 없음."""
suffixes = [Path(name).suffix.lower() for name in names]
if not any(s in LAS_SUFFIXES for s in suffixes):
raise HTTPException(status_code=400, detail="LAS 또는 LAZ 파일을 하나 이상 올리세요.")
if any(s not in (*LAS_SUFFIXES, ".prj") for s in suffixes):
raise HTTPException(status_code=400, detail="LAS · LAZ · PRJ 만 올릴 수 있습니다.")
if len({Path(name).name.casefold() for name in names}) != len(names):
raise HTTPException(status_code=400, detail="같은 이름 파일이 있습니다.")
def las_epsg(path: Path, prj_text: str | None) -> int | None:
"""LAS 좌표계 — .prj 먼저 · 없으면 LAS 머리 · 모르면 None."""
import laspy
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
if prj_text:
return identify_epsg(CRS.from_wkt(prj_text), prj_text)
with laspy.open(path) as reader:
crs = reader.header.parse_crs()
return identify_epsg(crs, crs.to_wkt()) if crs is not None else None
def place_las(src: Path, dst_dir: Path, src_epsg: int | None, epsg: int) -> Path:
"""올린 LAS → 지형 입력 자리 — 좌표계가 같거나 모르면 옮김 · 다르면 x · y 만 옮겨 적음(.las)."""
import laspy
from pyproj import CRS, Transformer
dst_dir.mkdir(parents=True, exist_ok=True)
if src_epsg in (None, int(epsg)):
return Path(shutil.move(src, dst_dir / src.name))
dst = dst_dir / f"{src.stem}.las"
move = Transformer.from_crs(src_epsg, int(epsg), always_xy=True)
with laspy.open(src) as reader:
old = reader.header
header = laspy.LasHeader(point_format=old.point_format, version=old.version)
header.scales = old.scales
x0, y0 = move.transform(old.mins[0], old.mins[1])
header.offsets = [round(x0), round(y0), old.offsets[2]]
header.add_crs(CRS.from_epsg(int(epsg)))
with laspy.open(dst, mode="w", header=header) as writer:
for points in reader.chunk_iterator(CHUNK):
x, y = move.transform(points.x, points.y)
record = laspy.ScaleAwarePointRecord.zeros(len(points), header=header)
record.array[:] = points.array
record.x, record.y, record.z = x, y, points.z
writer.write_points(record)
src.unlink()
return dst
async def receive_uploads(files: list[UploadFile], upload: Path) -> None:
"""올린 파일 → `upload/`(옛 것 비움) · 상한 넘음 = 413."""
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
shutil.rmtree(upload, ignore_errors=True)
upload.mkdir(parents=True)
try:
for item in files:
await save_upload_stream(item, upload / Path(item.filename or "").name)
except ValueError as error:
shutil.rmtree(upload, ignore_errors=True)
raise HTTPException(status_code=413, detail=str(error)) from error
def stage_las(upload: Path, las_dir: Path, epsg: int, report: Report) -> list[Path]:
"""`upload/` 의 LAS → 입력 자리 갈음(새로 올림 = 옛 LAS 지움) → 입력 LAS 목록."""
prj = next(iter(sorted(upload.glob("*.prj"))), None) if upload.is_dir() else None
prj_text = prj.read_text(encoding="utf-8", errors="ignore") if prj else None
sources = (
sorted(p for p in upload.iterdir() if p.suffix.lower() in LAS_SUFFIXES)
if upload.is_dir()
else []
)
if sources:
shutil.rmtree(las_dir, ignore_errors=True)
report(5, "LAS 좌표계 맞추는 중")
for path in sources:
place_las(path, las_dir, las_epsg(path, prj_text), epsg)
shutil.rmtree(upload, ignore_errors=True)
las_paths = sorted(p for p in las_dir.glob("*") if p.suffix.lower() in LAS_SUFFIXES)
if not las_paths:
raise FileNotFoundError("올린 LAS 가 없습니다.")
return las_paths
def build_surface(
base: Path,
las_paths: list[Path],
report: Report,
source_filters: list[str] | None = None,
methods: list[str] | None = None,
) -> dict[str, Any]:
"""B04 엔진으로 라이다 지형(지면 필터 · DTM · 평활) — `base/B04_PreProcess/models` 에 지음."""
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
from config.config_system import SURFACE_AUTO_METHODS
return run_surface_analysis(
base,
las_paths,
source_filters=source_filters,
methods=list(methods or SURFACE_AUTO_METHODS),
on_progress=lambda percent, _stage, message: report(10 + int(percent * 0.85), message),
maps=False,
)
# ── 뒤 일 · 진행 기록 ─────────────────────────────────────────────────────────
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
def note_job(path: Path | None, job: dict[str, Any]) -> None:
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, job)
except OSError as error:
logger.warning("진행 기록 실패: %s", error)
def saved_job(path: Path) -> dict[str, Any]:
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
message 없음)."""
try:
saved = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return dict(IDLE)
if not (isinstance(saved, dict) and saved.get("running")):
return dict(IDLE)
percent = min(99, int(saved.get("percent") or 0))
return {**IDLE, "percent": percent, "interrupted": True}
def start_job(
jobs: dict[tuple[str, str], dict[str, Any]],
tasks: set[asyncio.Task],
project_id: UUID,
kind: str,
work: Any,
path: Path | None = None,
label: str = "D",
) -> dict[str, Any]:
"""뒤 일 하나 — 같은 (프로젝트, 일)이 돌면 409 · 진행 = `jobs` · 기록 = `path`."""
key = (str(project_id), kind)
if (jobs.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False}
jobs[key] = job
note_job(path, job)
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
note_job(path, job)
async def run() -> None:
try:
await work(report)
job.update(percent=100)
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("%s %s 실패: project_id=%s", label, kind, project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
note_job(path, job)
task = asyncio.create_task(run(), name=f"{label} {kind} {project_id}")
tasks.add(task)
task.add_done_callback(tasks.discard)
return {"status": "accepted", **job}
# ── DB ─────────────────────────────────────────────────────────────────────
async def save_models(
project_id: UUID, models: list[dict[str, Any]], epsg: int, *, keep: bool = False
) -> None:
"""지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로).
`keep` = 같은 파일을 가리키는 옛 행은 지문이 달라도 id · 상태(확정) · 입력 연결을 살림
(LAS 갈음 — 노선 `surface_model_id` · 설계 확정이 끊기지 않게 · B05)."""
from B04_PreProcess.B04_PreProcess_Repository import (
_existing_models,
_model_key,
_upsert_surface_model,
confirm_surface_model,
)
from config.config_db import get_db_pool
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
existing = await _existing_models(connection, project_id)
olds: dict[int, tuple[Any, Any, str]] = {}
if keep:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT id, source_file_id, processed_cloud_id, status "
"FROM surface_models WHERE project_id = %s",
(str(project_id),),
)
olds = {int(r[0]): (r[1], r[2], str(r[3])) for r in await cursor.fetchall()}
for model in models:
params = model.get("generation_params") or {}
key = _model_key(model["model_type"], params, model["model_file_path"])
hit = existing.get(key) if keep else None
if hit is not None: # 지문 맞춤 = 고쳐 씀(id 살림)
existing[key] = (hit[0], params.get("file_stamp"))
source_file_id, cloud_id, status = olds.get(hit[0] if hit else -1, (None, None, ""))
model_id = await _upsert_surface_model(
connection,
existing,
project_id=project_id,
model=model,
source_file_id=source_file_id,
processed_cloud_id=cloud_id,
crs_epsg=epsg,
)
if status == "CONFIRMED": # 고쳐 쓰기가 COMPLETE 로 내린 확정을 되살림
await confirm_surface_model(connection, project_id, model_id)
await connection.commit()
except Exception:
await connection.rollback()
raise
@@ -185,3 +185,39 @@ async def merge_surface_confirmation_params(
(json.dumps(params, ensure_ascii=False), project_id),
)
return normalized
async def stash_las_selection(
connection: aiomysql.Connection,
project_id: str,
selection: dict[str, Any],
) -> None:
"""stage 1 params 에 라이다 확정값(`LAS_STASH_KEY`)만 적음 — 설계 지형은 그대로(B05 LAS 나중
올리기 · PLAN 637-2 · 지형 칩 라이다 열림). 트랜잭션은 부르는 쪽."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT params
FROM project_workflow_stages
WHERE project_id = %s AND stage_no = 1
FOR UPDATE
""",
(project_id,),
)
row = await cursor.fetchone()
if row is None:
raise LookupError("WF1 단계 상태를 찾을 수 없습니다.")
params = _decode_params(row.get("params"))
params[LAS_STASH_KEY] = {
"source_filter": str(selection["source_filter"]),
"method": str(selection["method"]),
"smooth": bool(selection["smooth"]),
}
await cursor.execute(
"""
UPDATE project_workflow_stages
SET params = %s
WHERE project_id = %s AND stage_no = 1
""",
(json.dumps(params, ensure_ascii=False), project_id),
)
+2
View File
@@ -46,6 +46,7 @@ from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_rou
from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router
from B05_Profile.B05_Profile_Router import router as b05_route_router
from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router
from B05_Profile.B05_Profile_Router_Lidar import router as b05_route_lidar_router
from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router
from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router
from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_router
@@ -512,6 +513,7 @@ app.include_router(e06_stab_rock_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
app.include_router(b05_route_lidar_router, dependencies=protected_with_company)
app.include_router(b05_structures_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
+6 -2
View File
@@ -210,7 +210,7 @@ def test_put_writes_file_and_year_status_only(client) -> None:
assert got.status_code == 200 and got.json()["derived"]["pieces"][1]["from_m"] == 75.0
assert got.json()["year_start"] == 2026
saved = json.loads(client.file.read_text("utf-8"))
assert saved["version"] == 1 and saved["epsg"] == 5186
assert saved["version"] == 2 and saved["epsg"] == 5186
assert saved["points"] == BASE["points"] # 몸에 지점 없음 = 그대로
assert [p["id"] for p in saved["pieces"]] == ["p1", "p2", "p3"]
assert "length_m" not in json.dumps(saved) # 파생 저장 안 함
@@ -228,7 +228,11 @@ def test_put_writes_file_and_year_status_only(client) -> None:
def test_put_reset_drops_year_rows(client) -> None:
got = client.put(URL, json={"epsg": 5186, "lines": [], "marks": [], "pieces": []})
assert got.status_code == 200 and got.json()["derived"] == {"lines": [], "pieces": []}
assert got.status_code == 200 and got.json()["derived"] == {
"lines": [],
"pieces": [],
"sets": [],
}
sqls = [s for s, _ in client.db.log]
assert "DELETE FROM plan_route_years WHERE project_id = %s" in sqls
assert not any("project_year_end" in s for s in sqls)
+5 -2
View File
@@ -64,6 +64,9 @@ def _assignee(**kw):
"project_id": PLAN_ID,
"year": 2026,
"piece_id": "p26",
"set_key": "2026-1", # 638-1 — 옛 조각 id p26 이 든 세트
"parts": 1,
"branch": False,
"company_id": 5,
"linked_project_id": None,
"name": "시험 예정노선",
@@ -400,13 +403,13 @@ def _zip_names(response):
def test_export_forest_gets_all(plan_repo):
forest = {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, forest)))
assert {n.split("/")[0] for n in names} == {"2026_본선_p26", "2027_본선_p27", "전체"}
assert {n.split("/")[0] for n in names} == {"2026-1_본선", "2027-1_본선", "전체"}
assert sum(n.endswith(".shp") for n in names) == 3
def test_export_shared_gets_own_confirmed_year(plan_repo):
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, SESSION)))
assert {n.split("/")[0] for n in names} == {"2026_본선_p26"} # 2027 = 확정 전 · 전체 없음
assert {n.split("/")[0] for n in names} == {"2026-1_본선"} # 2027 = 확정 전 · 전체 없음
with pytest.raises(HTTPException) as err:
asyncio.run(d04.export_plan(PLAN_ID, {**SESSION, "company_id": 9}))
assert err.value.status_code == 403
@@ -349,10 +349,10 @@ def test_finalize_sends_zip_per_assignee(fake_db):
assert to == "ga@x.kr" and "2026" in subject
assert set(fake_db["confirmed"]) == {2026, 2027} # 연도 → 파일 지문
name, blob, mime = attachments[0]
assert name == "시험_예정노선_2026_본선_p26.zip" and mime == "application/zip"
assert name == "시험_예정노선_2026-1_본선.zip" and mime == "application/zip" # 638-1 세트
assert len(blob) > 1000
with zipfile.ZipFile(io.BytesIO(blob)) as archive:
assert any(n.endswith("_2026_본선_p26.shp") for n in archive.namelist())
assert any(n.endswith("_2026_본선_2026-1.shp") for n in archive.namelist())
assert not any("_2027" in n for n in archive.namelist()) # 다른 연도는 안 샘
# 다시 보내기
+27 -23
View File
@@ -124,17 +124,18 @@ def _names(response) -> list[str]:
def test_summary_piece_rows_and_firm_view(plan):
out = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))
rows = out["pieces"]
assert [(r["piece_id"], r["year"], r["label"]) for r in rows] == [
("p1", 2026, "본선 0+0~12+10"),
("p3", 2026, "갈래 1 0+0~6+10"),
("p2", 2027, "본선 12+10~25+19.3"),
# 638-1 — 단위 = 세트(옛 조각 하나 = 세트 하나 · 그 해 선 차례 · 거리 차례 번호)
assert [(r["piece_id"], r["year"], r["label"], r["pieces"]) for r in rows] == [
("2026-1", 2026, "2026-1", ["p1"]),
("2026-2", 2026, "2026-2", ["p3"]),
("2027-1", 2027, "2027-1", ["p2"]),
]
assert rows[1]["length_m"] == 130.0 and rows[1]["start"]["x"] == P3[0][0]
assert rows[0]["confirmed_at"] == "x" and rows[2]["confirmed_at"] is None
assert [y["year"] for y in out["years"]] == [2026, 2027]
assert [(a["id"], a["year"]) for a in out["assignees"]] == [(7, 2026), (8, 2026)]
# 같은 연도 다른 업체 — 자기 조각 · 자기 행만
for session, piece, aid in ((FIRM5, "p1", 7), (FIRM6, "p3", 8)):
for session, piece, aid in ((FIRM5, "2026-1", 7), (FIRM6, "2026-2", 8)):
mine = asyncio.run(d04.get_plan_summary(PLAN_ID, session))
assert [r["piece_id"] for r in mine["pieces"]] == [piece]
assert [a["id"] for a in mine["assignees"]] == [aid]
@@ -145,9 +146,9 @@ def test_summary_piece_rows_and_firm_view(plan):
def test_export_piece_folders_and_firm_only_own(plan):
names = _names(asyncio.run(d04.export_plan(PLAN_ID, FOREST)))
assert {n.split("/")[0] for n in names} == {
"2026_본선_p1",
"2026_갈래1_p3",
"2027_본선_p2",
"2026-1_본선",
"2026-2_갈래1",
"2027-1_본선",
"전체",
}
assert sum(n.endswith(".shp") for n in names) == 4
@@ -164,7 +165,7 @@ def test_export_piece_folders_and_firm_only_own(plan):
round(250.0 + L2, 1),
]
firm = _names(asyncio.run(d04.export_plan(PLAN_ID, FIRM6)))
assert {n.split("/")[0] for n in firm} == {"2026_갈래1_p3"}
assert {n.split("/")[0] for n in firm} == {"2026-2_갈래1"}
plan["file"]["confirmed"].clear() # 확정 전 = 받을 것 없음
with pytest.raises(HTTPException) as err:
asyncio.run(d04.export_plan(PLAN_ID, FIRM6))
@@ -174,14 +175,15 @@ def test_export_piece_folders_and_firm_only_own(plan):
def test_finalize_mails_own_piece_zip(plan):
out = asyncio.run(d04.finalize_plan(PLAN_ID, d04.FinalizeRequest(years=[2026]), FOREST))
assert [(n["piece_id"], n["state"]) for n in out["notifications"]] == [
("p1", "sent"),
("p3", "sent"),
("2026-1", "sent"),
("2026-2", "sent"),
]
for (to, attachments), piece in zip(plan["mails"], ("본선_p1", "갈래1_p3"), strict=True):
pairs = (("2026-1", "본선"), ("2026-2", "갈래1"))
for (to, attachments), (key, line) in zip(plan["mails"], pairs, strict=True):
name, blob, _ = attachments[0]
assert name == f"시험_예정노선_2026_{piece}.zip"
assert name == f"시험_예정노선_{key}_{line}.zip"
shp = [n for n in zipfile.ZipFile(io.BytesIO(blob)).namelist() if n.endswith(".shp")]
assert len(shp) == 1 and shp[0].endswith(f"_2026_{piece}.shp")
assert len(shp) == 1 and shp[0].endswith(f"_2026_{line}_{key}.shp")
def test_put_assignees_by_piece(plan):
@@ -202,7 +204,9 @@ def test_put_assignees_by_piece(plan):
)
deleted, updates, inserts = plan["applied"]
assert deleted == [8] and [u["id"] for u in updates] == [7]
assert [(i["piece_id"], i["year"], i["company_id"]) for i in inserts] == [("p3", 2026, 5)]
# 옛 조각 id 로 보내도 세트 열쇠로 적음(638-1)
assert [(i["piece_id"], i["year"], i["company_id"]) for i in inserts] == [("2026-2", 2026, 5)]
assert [u["piece_id"] for u in updates] == ["2026-1"]
def test_confirm_drops_when_file_changes(plan, monkeypatch):
@@ -280,7 +284,7 @@ def test_assigned_list_piece_label_length(plan, monkeypatch):
monkeypatch.setattr(d01, "get_db_pool", lambda: _Conn(rows))
out = asyncio.run(d01.assigned_plan_routes(FIRM6))["routes"]
assert [(r["piece_id"], r["year"], r["piece_label"], r["length_m"]) for r in out] == [
("p3", 2026, "갈래 1 0+0~6+10", 130.0),
("2026-2", 2026, "2026-2", 130.0),
]
@@ -289,17 +293,17 @@ def test_line_name_flows_to_labels_zip_xlsx(plan):
named = [{**LINES[0], "name": "용화 본선"}, LINES[1]]
plan["file"]["plan"] = plan_of(named, MARKS, PIECES)
rows = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))["pieces"]
assert [r["label"] for r in rows] == [
"용화 본선 0+0~12+10",
"갈래 1 0+0~6+10",
"용화 본선 12+10~25+19.3",
assert [(r["label"], r["line_name"]) for r in rows] == [
("2026-1", "용화 본선"),
("2026-2", "갈래 1"),
("2027-1", "용화 본선"),
]
body = asyncio.run(d04.export_plan(PLAN_ID, FOREST)).body
with zipfile.ZipFile(io.BytesIO(body)) as z:
assert {n.split("/")[0] for n in z.namelist()} == {
"2026_용화본선_p1",
"2026_갈래1_p3",
"2027_용화본선_p2",
"2026-1_용화본선",
"2026-2_갈래1",
"2027-1_용화본선",
"전체",
}
book = next(n for n in z.namelist() if n.startswith("전체/") and n.endswith(".xlsx"))
+4
View File
@@ -55,6 +55,8 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"expected-draw",
"expected-original",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
@@ -66,6 +68,8 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"expected-draw",
"expected-original",
}
+5 -5
View File
@@ -105,7 +105,7 @@ def test_atomic_write_keeps_old_file_on_failure(tmp_path, monkeypatch):
path = store.plan_path(tmp_path)
assert (
path.parent.name == "D03_PlanAnalysis"
and json.loads(path.read_text("utf-8"))["version"] == 1
and json.loads(path.read_text("utf-8"))["version"] == 2 # 638-1 판 2(조각 set)
)
assert store.read_plan(tmp_path) == plan
@@ -124,7 +124,7 @@ def test_atomic_write_keeps_old_file_on_failure(tmp_path, monkeypatch):
def test_newer_version_refused(tmp_path):
path = store.plan_path(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(json.dumps({**store.empty_plan(), "version": 2}), "utf-8")
path.write_text(json.dumps({**store.empty_plan(), "version": 3}), "utf-8")
with pytest.raises(HTTPException) as err:
store.read_plan(tmp_path)
assert err.value.status_code == 409
@@ -170,10 +170,10 @@ def test_two_saves_do_not_interleave(monkeypatch, tmp_path):
async def project_info(_pid):
return {"year_start": 2026, "site": None}
async def slow_status(connection, project_id, pieces, years):
order.append(f"start {len(pieces)}")
async def slow_status(connection, project_id, keys, years, before=None):
order.append(f"start {len(keys)}")
await asyncio.sleep(0.05)
order.append(f"end {len(pieces)}")
order.append(f"end {len(keys)}")
class Conn:
async def __aenter__(self):
@@ -0,0 +1,68 @@
"""634-1 부품(요구 546) — 여럿 고르기 메뉴(안 닫힘 · 켠 수 배지 · 줄 아이콘 · 막힘) · 한 행 두 영역 · 견본 · 아이콘."""
import re
from pathlib import Path
UI = Path(__file__).resolve().parents[2] / "ui_template"
MENU = (UI / "ui_template_context_menu.ts").read_text(encoding="utf-8")
MENU_CSS = (UI / "ui_template_context_menu.css").read_text(encoding="utf-8")
TOOLS = (UI / "ui_template_map_tools.ts").read_text(encoding="utf-8")
TOOLS_CSS = (UI / "ui_template_map_tools.css").read_text(encoding="utf-8")
ICONS = (UI / "ui_template_icons.ts").read_text(encoding="utf-8")
def test_여럿_고르기_안_닫힘_배지():
pick = MENU[MENU.index("export function createPickMenu(") :]
assert '"menuitemcheckbox"' in pick and "values," in pick and "setItem(value, patch)" in pick
click = pick[pick.index('b.addEventListener("click"') :]
click = click[: click.index("pop.append(b);")]
# 여럿 = 켬끔 뒤 바로 돌아감(setOpen(false) 앞)
assert click.index("if (multi)") < click.index("setOpen(false)")
assert (
'createBadge("0", "accent", { kind: "count"' in pick
and "badge.hidden = count === 0;" in pick
)
assert 'root.setAttribute("aria-pressed", String(count > 0));' in pick
assert 'window.addEventListener("resize", onShift);' in pick # 창 크기 = 닫힘
assert 'document.addEventListener("scroll", onShift, true);' in pick # 밀기 = 닫힘
assert ".ui-pick-menu--multi" in MENU_CSS and ".ui-row-menu__item:disabled" in MENU_CSS
def test_한_행_두_영역():
assert "export function createMapToolRow(left: HTMLElement, right: HTMLElement)" in TOOLS
assert ":scope > .ui-map-tools__hint" in TOOLS # [?] 판은 행 아래
first = TOOLS_CSS[TOOLS_CSS.index(".ui-map-row__line > .ui-map-tools:first-child {") :]
first = first[: first.index("}")]
assert "flex-shrink: 0;" in first and "max-width: 50%;" in first # 모자라면 그룹2 먼저
area = TOOLS_CSS[TOOLS_CSS.index(".ui-map-row__line > .ui-map-tools {") :]
assert "min-width: 0;" in area[: area.index("}")]
def test_마우스_글_막힘만_까닭():
tip = TOOLS[TOOLS.index("export function mapToolTip(") :]
assert "why ? `${el.dataset.tip} — ${why}` : el.dataset.tip" in tip[: tip.index("\n}")]
assert 'title: "도움말"' in TOOLS
def test_아이콘_겹침_없음():
table = TOOLS[TOOLS.index("export const MAP_TOOLS") : TOOLS.index("satisfies")]
icons = re.findall(r'icon: "(\w+)"', table)
assert len(icons) == len(set(icons))
for name in (
"contours",
"cutFill",
"lineament",
"spacing",
"streams",
"signpost",
"parcelHouse",
"ban",
):
assert re.search(rf"^ {name}:", ICONS, re.M), name
def test_견본():
parts = (UI / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8")
frames = (UI / "ui_template_ui_kit_frames.ts").read_text(encoding="utf-8")
assert 'name: "고르기 메뉴(하나 · 여럿)"' in parts and "demo: pickDemo" in parts
assert "createMapToolRow(layers.root, tools.root)" in frames
+187
View File
@@ -0,0 +1,187 @@
"""예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — `A00_Common/map/logic/map_guide_route.ts`.
2 m 격자 A*(20 m 걸음 · ±14 % 막음 · 급경사 벌점 · 꺾임 벌점 · 펼침 상한).
평지 · 높이 차 지그재그(모든 걸음 ≤ 상한 · 첫 · 끝 걸음 포함) · 못 닿음 ≤ 0.5 s · 지점 셋 ·
급경사 벌점 효과 · 1 km ≤ 0.3 s · 1 m 격자 솎음 · 지형 밖.
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
./venv/Scripts/python.exe -m pytest resources/tester/test_636_guide_route.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_guide_route.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GMAX = 0.14
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { guideRoute } from "./A00_Common/map/logic/map_guide_route.js";
// 산 꼴 — 급경사 35°+ 약 2 할 · 동쪽으로 12 % 오름
const hills = (x, y) =>
10 * Math.sin(x / 37) * Math.cos(y / 53) + 6.25 * Math.sin((x + 2 * y) / 19) + 0.12 * x;
const TERRAIN = {
flat: () => 100,
plane: (x) => 0.3 * x,
hills,
cliff: (x, y) => hills(x, y) + (x >= 0 ? 500 : 0),
hole: (x, y) => (Math.hypot(x, y) < 50 ? NaN : 0),
};
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {};
for (const [key, c] of Object.entries(cases)) {
const terrain = { heightAt: TERRAIN[c.terrain], cell: c.cell ?? 2 };
const runs = [];
for (let k = 0; k < (c.repeat ?? 1); k++)
runs.push(guideRoute(c.points, terrain, c.options ?? {}));
out[key] = { ...runs[runs.length - 1], allMs: runs.map((r) => r.ms) };
}
writeFileSync(process.argv[3], JSON.stringify(out));
"""
CASES = {
"flat": {"terrain": "flat", "points": [[0.7, 0.3], [500.4, 3.1]]},
"zigzag": {"terrain": "plane", "points": [[0.7, 0.3], [300.9, 1.3]]},
"cliff": {"terrain": "cliff", "points": [[-500.0, 0.0], [500.0, 0.0]]},
"three": {"terrain": "hills", "points": [[0.0, 0.0], [400.0, 300.0], [700.0, -100.0]]},
"steep0": {
"terrain": "hills",
"points": [[0.0, 0.0], [600.0, 400.0]],
"options": {"weights": {"steep": 0}},
},
"steep3": {"terrain": "hills", "points": [[0.0, 0.0], [600.0, 400.0]]},
"km": {"terrain": "hills", "points": [[0.0, 0.0], [800.0, 600.0]], "repeat": 3},
"lidar": {"terrain": "hills", "cell": 1, "points": [[0.0, 0.0], [300.0, 200.0]]},
"outside": {"terrain": "hole", "points": [[0.0, 0.0], [300.0, 0.0]]},
}
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("guide_route")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job, result = tmp / "job.json", tmp / "out.json"
job.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _grades(vertices: list) -> list[float]:
return [
abs(b[2] - a[2]) / math.hypot(b[0] - a[0], b[1] - a[1])
for a, b in zip(vertices, vertices[1:])
]
def _check_ends(seg: dict, start: list, goal: list) -> None:
assert seg["vertices"][0][:2] == start
assert seg["vertices"][-1][:2] == goal
def test_평지_곧게(r):
(seg,) = r["flat"]["segments"]
assert seg["reached"]
_check_ends(seg, *CASES["flat"]["points"])
span = math.dist(*CASES["flat"]["points"])
assert seg["length"] <= span * 1.1
assert seg["maxGrade"] == 0 and seg["steepRatio"] == 0
def test_높이_차_지그재그_모든_걸음_상한_안(r):
(seg,) = r["zigzag"]["segments"]
assert seg["reached"]
_check_ends(seg, *CASES["zigzag"]["points"])
grades = _grades(seg["vertices"])
assert max(grades) <= GMAX + 1e-9 # 첫 · 끝 걸음 포함
assert seg["maxGrade"] == pytest.approx(max(grades))
assert seg["length"] >= 0.3 * 300 / GMAX # 높이 차 90 m 를 14 % 로
steps = [math.dist(a[:2], b[:2]) for a, b in zip(seg["vertices"], seg["vertices"][1:])]
assert all(19 <= s <= 21.5 for s in steps[:-1]) # 꼭짓점 그대로(끝 걸음만 짧을 수 있음)
def test_못_닿음_가장_가까이_간_데까지_빠르게(r):
(seg,) = r["cliff"]["segments"]
assert not seg["reached"]
assert seg["vertices"][0][:2] == CASES["cliff"]["points"][0]
assert -40 <= seg["vertices"][-1][0] < 0 # 벼랑 앞까지
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert r["cliff"]["ms"] <= 500
def test_지점_셋_구간마다_이어짐(r):
points = CASES["three"]["points"]
segs = r["three"]["segments"]
assert len(segs) == 2
for seg, a, b in zip(segs, points, points[1:]):
assert seg["reached"]
_check_ends(seg, a, b)
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert segs[0]["vertices"][-1] == segs[1]["vertices"][0]
def test_급경사_벌점이_급경사_지남을_줄임(r):
off = r["steep0"]["segments"][0]
on = r["steep3"]["segments"][0]
assert off["reached"] and on["reached"]
assert on["steepRatio"] < off["steepRatio"]
def test_1_km_0_3_초_안(r):
(seg,) = r["km"]["segments"]
assert seg["reached"]
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
assert min(r["km"]["allMs"]) <= 300
def test_1_m_격자는_2_m_로_솎음(r):
(seg,) = r["lidar"]["segments"]
assert seg["reached"]
inner = seg["vertices"][1:-1]
assert inner and all(v[0] % 2 == 0 and v[1] % 2 == 0 for v in inner)
def test_지형_밖_지점은_못_닿음(r):
(seg,) = r["outside"]["segments"]
assert not seg["reached"]
assert len(seg["vertices"]) == 1
+261
View File
@@ -0,0 +1,261 @@
"""PLAN 637 — LAS 나중 올리기 공용(`common_util_lidar_upload`) · B05 창구(요구 546).
637-1 = 예정노선 길을 공용으로(동작 그대로 · `test_624_4_plan_lidar` 함께) —
갈음 · 짓기 · DB 넣기/고치기만.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
import pytest
from common_util import common_util_lidar_upload as lidar_upload
PID = UUID("00000000-0000-0000-0000-000000000637")
EPSG = 5186
def write_las(path: Path, x0: float, y0: float, size: float = 60.0, n: int = 6000) -> None:
"""지면점 LAS(비탈 0.3 · 좌표계 없음)."""
import laspy
rng = np.random.default_rng(7)
x, y = rng.uniform(x0, x0 + size, n), rng.uniform(y0, y0 + size, n)
header = laspy.LasHeader(point_format=3, version="1.2")
header.offsets = [round(x0), round(y0), 0]
header.scales = [0.01, 0.01, 0.01]
data = laspy.LasData(header)
data.x, data.y, data.z = x, y, 500 + 0.3 * (x - x0)
data.classification = np.full(len(x), 2, np.uint8)
path.parent.mkdir(parents=True, exist_ok=True)
data.write(path)
def test_갈음_짓기_지도_안_받음(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
import B04_PreProcess.B04_PreProcess_Engine as engine
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: pytest.fail("지도 받기"))
upload, las_dir = tmp_path / "up", tmp_path / "las"
write_las(las_dir / "old.las", 200000.0, 500000.0)
write_las(upload / "new.las", 200000.0, 500000.0)
got = lidar_upload.stage_las(upload, las_dir, EPSG, lambda *_: None)
assert [p.name for p in got] == ["new.las"] and not upload.exists() # 옛 LAS 갈음
again = lidar_upload.stage_las(upload, las_dir, EPSG, lambda *_: None)
assert [p.name for p in again] == ["new.las"] # 올림 없음 = 있는 것 그대로
seen: list[int] = []
result = lidar_upload.build_surface(tmp_path, got, lambda p, _m: seen.append(p))
picked = result["source_filters"][0]
assert (tmp_path / "B04_PreProcess" / "models" / f"dtm_{picked}.npz").is_file()
assert seen and min(seen) >= 10 and max(seen) <= 95
with pytest.raises(FileNotFoundError):
lidar_upload.stage_las(upload, tmp_path / "빈", EPSG, lambda *_: None)
def test_진행_기록_끊김(tmp_path: Path) -> None:
path = tmp_path / "job.json"
assert lidar_upload.saved_job(path) == lidar_upload.IDLE
lidar_upload.note_job(path, {"running": True, "percent": 42})
assert lidar_upload.saved_job(path) == {**lidar_upload.IDLE, "percent": 42, "interrupted": True}
class _Cursor:
def __init__(self, rows: list[tuple]) -> None:
self.rows = rows
async def __aenter__(self) -> "_Cursor":
return self
async def __aexit__(self, *_: Any) -> None:
return None
async def execute(self, *_: Any) -> None:
return None
async def fetchall(self) -> list[tuple]:
return self.rows
class _Connection:
def __init__(self, rows: list[tuple]) -> None:
self.rows, self.done = rows, []
async def begin(self) -> None:
return None
async def commit(self) -> None:
self.done.append("commit")
async def rollback(self) -> None:
self.done.append("rollback")
def cursor(self) -> _Cursor:
return _Cursor(self.rows)
class _Pool:
def __init__(self, connection: _Connection) -> None:
self.connection = connection
def acquire(self) -> "_Pool":
return self
async def __aenter__(self) -> _Connection:
return self.connection
async def __aexit__(self, *_: Any) -> None:
return None
def _save(monkeypatch, keep: bool) -> tuple[list[dict], list[int], _Connection]: # type: ignore[no-untyped-def]
"""옛 행 둘(확정 dtm_a · 수치지형도) + 새 모델 dtm_a(지문 다름) → 부른 값."""
import asyncio
import B04_PreProcess.B04_PreProcess_Repository as repo
import config.config_db as config_db
path_a = "B04_PreProcess/models/dtm_a.npz"
existing = {("dtm", "a", path_a): (11, "old"), ("dtm", "sheet_laplace", "s.npz"): (12, "s")}
calls: list[dict] = []
confirmed: list[int] = []
async def existing_models(*_: Any) -> dict:
return dict(existing)
async def upsert(_c: Any, have: dict, **kw: Any) -> int:
key = ("dtm", "a", path_a)
calls.append({**kw, "have": have.get(key)})
return 11 if have.get(key, (0, ""))[1] == "new" else 99
async def confirm(_c: Any, _p: Any, model_id: int) -> None:
confirmed.append(model_id)
connection = _Connection([(11, 1320, 7, "CONFIRMED"), (12, None, None, "COMPLETE")])
monkeypatch.setattr(repo, "_existing_models", existing_models)
monkeypatch.setattr(repo, "_upsert_surface_model", upsert)
monkeypatch.setattr(repo, "confirm_surface_model", confirm)
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool(connection))
model = {
"model_type": "dtm",
"model_file_path": path_a,
"generation_params": {"source_filter": "a", "file_stamp": "new"},
}
asyncio.run(lidar_upload.save_models(PID, [model], EPSG, keep=keep))
return calls, confirmed, connection
def test_DB_넣기_고치기만_갈음은_id_확정_살림(monkeypatch) -> None: # type: ignore[no-untyped-def]
calls, confirmed, connection = _save(monkeypatch, keep=False) # 예정노선 = 옛 그대로
assert calls[0]["have"] == (11, "old") and calls[0]["source_file_id"] is None
assert confirmed == [] and connection.done == ["commit"]
calls, confirmed, _ = _save(monkeypatch, keep=True) # B 갈음 = 지문 맞춤 · 연결 · 확정 살림
assert calls[0]["have"] == (11, "new")
assert (calls[0]["source_file_id"], calls[0]["processed_cloud_id"]) == (1320, 7)
assert confirmed == [11]
# ── 637-2 B05 창구 ──────────────────────────────────────────────────────────
def test_라이다_확정값_고르기() -> None:
from B05_Profile import B05_Profile_Router_Lidar as b05
las = {"source_filter": "classification", "method": "dtm", "smooth": True}
assert b05.las_target(las) == las # 설계 = 라이다 → 그 필터로 갈음
sheet = {"source_filter": "sheet_laplace", "method": "dtm", "smooth": True}
assert b05.las_target(sheet) is None # 수치지형도만 = 새로 덧붙임(자동 필터)
assert b05.las_target({**sheet, "las_selection": las}) == las # 적어 둔 것
def test_노선_2m_나눔() -> None:
from B05_Profile import B05_Profile_Router_Lidar as b05
pts = b05.line_points([[0, 0], [10, 0], [10, 3]])
assert pts[0].tolist() == [0, 0] and pts[-1].tolist() == [10, 3]
assert len(pts) == 1 + 5 + 2 and np.hypot(*np.diff(pts, axis=0).T).max() <= 2.0 + 1e-9
def _route_build(tmp_path: Path, monkeypatch, line, target=None) -> dict: # type: ignore[no-untyped-def]
import B04_PreProcess.B04_PreProcess_Engine as engine
from B05_Profile import B05_Profile_Router_Lidar as b05
from B05_Profile import B05_Profile_Router_Replan as replan
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: pytest.fail("지도 받기"))
monkeypatch.setattr(replan, "_planned_line", lambda _root: line)
write_las(tmp_path / b05.UPLOAD / "t.las", 200000.0, 500000.0)
return b05.build_route_lidar(tmp_path, EPSG, target, lambda *_: None)
def test_B_뿌리_짓기_수치지형도_그대로_덮임(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
from B05_Profile import B05_Profile_Router_Lidar as b05
sheet = tmp_path / b05.MODELS / "dtm_sheet_laplace.npz"
sheet.parent.mkdir(parents=True)
sheet.write_bytes(b"sheet")
got = _route_build(tmp_path, monkeypatch, [[200010, 500010], [200050, 500050]])
meta = got["meta"]
assert sheet.read_bytes() == b"sheet" # 수치지형도 모델 파일 그대로
assert (tmp_path / b05.MODELS / f"dtm_{meta['selection']['source_filter']}.npz").is_file()
assert meta["covered"] is True and meta["covered_ratio"] == 1.0
assert meta["files"] == ["t.las"] and not (tmp_path / b05.UPLOAD).exists()
assert all(m["model_file_path"].startswith("B04_PreProcess/models/") for m in got["models"])
assert b05.read_meta(tmp_path)["selection"] == meta["selection"]
# 노선 반이 라이다 밖 = 덮임 거짓
again = _route_build(tmp_path, monkeypatch, [[200010, 500030], [200110, 500030]])["meta"]
assert again["covered"] is False and 0.3 < again["covered_ratio"] < 0.7
# 있던 라이다 확정값 = 그 필터로 짓기
target = {"source_filter": "classification", "method": "dtm", "smooth": True}
kept = _route_build(tmp_path, monkeypatch, [[200010, 500010], [200020, 500020]], target)
assert kept["meta"]["selection"] == target
assert {m["source_filter"] for m in kept["models"]} == {"classification"}
class _DictCursor(_Cursor):
def __init__(self, params: dict) -> None:
super().__init__([])
self.params, self.sql = params, []
async def execute(self, sql: str, args: tuple = ()) -> None:
self.sql.append((sql, args))
async def fetchone(self) -> dict:
import json
return {"params": json.dumps(self.params)}
def test_las_selection_적기_칩_열림(tmp_path: Path) -> None:
import asyncio
import json
from common_util import common_util_surface_confirmation as confirm
params = {"source_filter": "sheet_laplace", "method": "dtm", "smooth": True}
assert confirm.analysis_terrain_sources(params, tmp_path) == ["sheet"] # 칩 라이다 막힘
cursor = _DictCursor(params)
connection = type("C", (), {"cursor": lambda self, *_: cursor})()
pick = {"source_filter": "csf", "method": "dtm", "smooth": True}
asyncio.run(confirm.stash_las_selection(connection, str(PID), pick))
written = json.loads(cursor.sql[-1][1][0])
assert written["las_selection"] == pick
assert written["source_filter"] == "sheet_laplace" # 설계 지형 그대로
assert confirm.analysis_terrain_sources(written, tmp_path) == ["las", "sheet"]
assert confirm.analysis_terrain_params(written, "las")["source_filter"] == "csf"
def test_지형_바꿈_force_같은_지형도_다시_셈(monkeypatch) -> None: # type: ignore[no-untyped-def]
import test_626_1_recommend_preview as base
from B05_Profile import B05_Profile_Router_TerrainSwitch as switch
request = switch.DesignTerrainRequest
assert request(terrain="las").force is False
monkeypatch.setattr(
switch, "DesignTerrainRequest", lambda terrain: request(terrain=terrain, force=True)
)
result, calls, line = base._run(monkeypatch, "las", "las")
assert result["changed"] is True and calls["args"][4] == "las"
assert calls["args"][5] == (line, line) # 노선 그대로
+263
View File
@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""638-1 예정노선 노선 세트(연도-번호 · 요구 547 · 548) — 서버.
- 조각 `set` · 옛 조각(set 없음) = 그 해 선 차례 · 거리 차례로 최댓값 + 1
- 같은 선 이웃 같은 세트 = 하나(앞 조각 id · 사이 마크 걷음)
- 세트 셈 `derived.sets[]` — 덩이 수 · 갈래 · 길이 · 조각
- 업체 줄 열쇠 = 세트 · 옛 조각 id = 든 세트 · 사라진 세트 409 · 옛 열쇠 → 세트 열쇠
- D04 확정 막음(덩이 2 이상 · 갈래) · 연결 = 세트 한 줄(아니면 거절) · 파일 판 2
- 거울 사례(`fixtures/d03_638_set_cases.json`) = TS `logic/D03_PlanAnalysis_Group.ts` 와 같은 답
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from helper_plan_file import plan_of, use_plan # noqa: E402
from D03_PlanAnalysis import D03_PlanAnalysis_Network as net # noqa: E402
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Link as link # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Router as d04 # noqa: E402
PLAN_ID = str(uuid4())
FOREST = {"role": "SYSTEM_ADMIN", "email": "root@x.kr", "company_id": 1, "user_id": 1}
MAIN = {"id": "main", "vertices": [[0.0, 0.0], [100.0, 0.0], [200.0, 0.0], [300.0, 0.0]]}
def _piece(pid, a, b, year=2027, no=None, line="main"):
out = {"id": pid, "line": line, "from_m": a, "to_m": b, "year": year}
return {**out, "set": no} if no else out
def _sets(plan):
return [
(s["key"], s["parts"], s["branch"], round(s["length_m"], 3), s["pieces"])
for s in net.derive(plan)["sets"]
]
def test_old_pieces_numbered_by_line_then_distance():
plan = plan_of(
[MAIN],
[{"line": "main", "at_m": 100.0}, {"line": "main", "at_m": 200.0}],
[_piece("p3", 200.0, 300.0), _piece("p1", 0.0, 100.0, 2028), _piece("p2", 100.0, 200.0)],
)
assert _sets(plan) == [
("2027-1", 1, False, 100.0, ["p2"]),
("2027-2", 1, False, 100.0, ["p3"]),
("2028-1", 1, False, 100.0, ["p1"]),
]
# 있는 번호 뒤로(새 조각 = 그 해 최댓값 + 1)
plan["pieces"][1]["year"] = 2027
plan["pieces"][2]["set"] = 5
keys = {p["id"]: p["set_key"] for p in net.derive(plan)["pieces"]}
assert keys == {"p1": "2027-6", "p2": "2027-5", "p3": "2027-7"}
def test_same_set_neighbours_merge_and_drop_mark():
plan = plan_of(
[MAIN],
[{"line": "main", "at_m": 100.0}, {"line": "main", "at_m": 200.0}],
[
_piece("a", 0.0, 100.0, no=1),
_piece("b", 100.0, 200.0, no=1),
_piece("c", 200, 300, no=2),
],
)
flat = net.normalize(plan)
assert flat["marks"] == [{"line": "main", "at_m": 200.0}]
assert [(p["id"], p["from_m"], p["to_m"], p["set"]) for p in flat["pieces"]] == [
("a", 0.0, 200.0, 1),
("c", 200, 300, 2),
]
assert _sets(plan) == [("2027-1", 1, False, 200.0, ["a"]), ("2027-2", 1, False, 100.0, ["c"])]
assert net.normalize(flat) == flat # 두 번 해도 같음
# 다른 연도 같은 번호 = 안 합침
plan["pieces"][1]["year"] = 2028
assert len(net.normalize(plan)["pieces"]) == 3
def test_parts_branch_and_join():
# 같은 세트가 다른 세트를 사이에 두고 끊김 → 2 덩이
gap = plan_of(
[MAIN],
[{"line": "main", "at_m": 100.0}, {"line": "main", "at_m": 200.0}],
[_piece("a", 0, 100, no=1), _piece("b", 100, 200, no=2), _piece("c", 200, 300, no=1)],
)
assert _sets(gap)[0] == ("2027-1", 2, False, 200.0, ["a", "c"])
# 본선 가운데 꼭짓점에서 갈래 · 같은 세트 → 갈래
fork = plan_of(
[
MAIN,
{
"id": "b",
"vertices": [[100.0, 0.0], [100.0, 50.0]],
"branch": {"line": "main", "vertex": 1},
},
],
[],
[_piece("m", 0, 300, no=1), _piece("g", 0, 50, no=1, line="b")],
)
assert _sets(fork) == [("2027-1", 1, True, 350.0, ["m", "g"])]
# 끝끼리 닿은 다른 선(뒤집힌 방향) = 한 덩이 · 이은 한 줄
tail = {"id": "t", "vertices": [[400.0, 0.0], [300.0, 0.0]]}
chain = plan_of(
[MAIN, tail], [], [_piece("m", 0, 300, no=1), _piece("t", 0, 100, no=1, line="t")]
)
assert _sets(chain) == [("2027-1", 1, False, 400.0, ["m", "t"])]
_, units = store.plan_parts(chain)
assert units[0]["planned"] == [[0, 0], [100, 0], [200, 0], [300, 0], [400, 0]]
assert (units[0]["from_m"], units[0]["to_m"]) == (0.0, 400.0)
# 끊긴 세트 · 갈래 세트 = 이은 줄 없음
assert store.plan_parts(gap)[1][0]["planned"] == []
assert store.plan_parts(fork)[1][0]["planned"] == []
def test_find_unit_old_piece_key():
plan = plan_of(
[MAIN], [{"line": "main", "at_m": 100.0}], [_piece("p1", 0, 100), _piece("p2", 100, 300)]
)
_, units = store.plan_parts(plan)
assert [u["id"] for u in units] == ["2027-1", "2027-2"]
assert store.find_unit(units, "2027-2")["pieces"] == ["p2"]
assert store.find_unit(units, "p2")["id"] == "2027-2" # 옛 업체 열쇠
assert store.find_unit(units, "없음") is None
assert net.old_keys(plan) == {"p1": "2027-1", "p2": "2027-2"}
def test_file_version_two(tmp_path):
assert store.VERSION == 2
plan = plan_of([MAIN], [], [_piece("p1", 0, 300, no=1)])
store.save_plan(tmp_path, plan)
saved = json.loads(store.plan_path(tmp_path).read_text("utf-8"))
assert saved["version"] == 2 and saved["pieces"][0]["set"] == 1
# 판 1(set 없음) = 읽힘 · 보기에 번호
store.plan_path(tmp_path).write_text(
json.dumps({**saved, "version": 1, "pieces": [_piece("p1", 0, 300)]}), "utf-8"
)
old = store.read_plan(tmp_path)
view = net._view(old, {"site": None, "year_start": 2027})
assert (
view["network"]["pieces"][0]["set"] == 1 and view["derived"]["sets"][0]["key"] == "2027-1"
)
assert net.Piece(**_piece("p1", 0, 1)).set is None # 옛 화면 몸 = 받음
with pytest.raises(ValueError):
net.Piece(**_piece("p1", 0, 1), set=0)
class _Cursor:
def __init__(self, held):
self.held, self.log, self.rows = held, [], []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def execute(self, sql, args=()):
self.log.append((sql, args))
self.rows = [(k,) for k in self.held] if sql.startswith("SELECT DISTINCT") else []
async def fetchall(self):
return self.rows
class _Conn:
def __init__(self, held):
self.c = _Cursor(held)
def cursor(self):
return self.c
def test_store_status_lost_set_and_old_key_rewrite():
conn = _Conn({"p1", "2027-2"})
years = {2027: {"fingerprint": "f"}}
asyncio.run(net._store_status(conn, PLAN_ID, {"2027-1", "2027-2"}, years, {"p1": "2027-1"}))
moved = [a for s, a in conn.c.log if s.startswith("UPDATE plan_route_assignees")]
assert moved == [("2027-1", PLAN_ID, "p1")]
conn = _Conn({"p1", "2027-9"})
with pytest.raises(HTTPException) as err:
asyncio.run(net._store_status(conn, PLAN_ID, {"2027-1"}, years, {"p1": "2027-1"}))
assert err.value.status_code == 409 and "2027-9" in err.value.detail
@pytest.fixture
def plan(monkeypatch):
gap = plan_of(
[MAIN],
[{"line": "main", "at_m": 100.0}, {"line": "main", "at_m": 200.0}],
[_piece("a", 0, 100, no=1), _piece("b", 100, 200, no=2), _piece("c", 200, 300, no=1)],
)
state = use_plan(monkeypatch, gap)
async def project(_pid):
return {"id": PLAN_ID, "name": "시험", "region": "용화", "project_kind": "PLAN"}
async def none(*a):
return []
monkeypatch.setattr(repo, "get_plan_project", project)
monkeypatch.setattr(repo, "list_assignees", none)
monkeypatch.setattr(repo, "mark_confirmed", none)
return state
def test_d04_units_and_finalize_blocked(plan):
out = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))
assert [(r["piece_id"], r["parts"], r["saved"]) for r in out["pieces"]] == [
("2027-1", 2, False),
("2027-2", 1, True),
]
with pytest.raises(HTTPException) as err:
asyncio.run(d04.finalize_plan(PLAN_ID, d04.FinalizeRequest(years=[2027]), FOREST))
assert err.value.status_code == 409 and "2027-1 2 덩이" in err.value.detail
assert "예정노선에서" in err.value.detail
def test_link_needs_one_line(plan, monkeypatch):
route = asyncio.run(link.piece_route(PLAN_ID, "a")) # 옛 조각 id → 세트
assert (route["set_key"], route["parts"], route["route"]["planned"]) == ("2027-1", 2, [])
async def load(_id):
return {"id": 7, "company_id": 5, "project_id": PLAN_ID, "piece_id": "a", **route}
monkeypatch.setattr(link, "load_assignee", load)
payload = link.LinkRequest(mode="new")
with pytest.raises(HTTPException) as err:
asyncio.run(link.link_plan_route(7, payload, {"company_id": 5, "user_id": 3}))
assert (
err.value.status_code == 409
and "2027-1" in err.value.detail
and "2 덩이" in err.value.detail
)
one = asyncio.run(link.piece_route(PLAN_ID, "2027-2"))
assert one["route"]["planned"] == [[100.0, 0.0], [200.0, 0.0]] and one["parts"] == 1
CASES = ROOT / "resources" / "tester" / "fixtures" / "d03_638_set_cases.json"
@pytest.mark.skipif(not CASES.is_file(), reason="거울 사례 파일 = sub6 638-2 가 만듦")
def test_mirror_cases_match_ts():
cases = json.loads(CASES.read_text("utf-8"))
assert cases["cases"]
for case in cases["cases"]:
net.derive(case["network"], cases["year_start"]) # 틀린 사례 없음
got = net.normalize(case["network"])["pieces"]
keys = ("id", "line", "from_m", "to_m", "year", "set")
assert [{k: p[k] for k in keys} for p in got] == case["expect"], case["name"]
+6
View File
@@ -84,3 +84,9 @@
.ui-badge[hidden] {
display: none !important;
}
/* 수 · 진행색(634-1 · 지도 겹 묶음 켠 수) */
.ui-badge--count.ui-badge--accent {
background: var(--color-primary);
color: var(--color-primary-text);
}
+26
View File
@@ -128,6 +128,32 @@
visibility: visible;
}
/* 줄 아이콘(634-1) — ✓ · 아이콘 · 글 한 줄 */
.ui-pick-menu__pop .ui-row-menu__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ui-pick-menu__pop .ui-row-menu__item:disabled {
opacity: 0.55;
}
/* 여럿 고르기 단추(634-1) — 아이콘 + 켠 수 배지 + ▾ */
.ui-icon-btn.ui-pick-menu--multi {
width: auto;
min-width: var(--ui-size-h);
grid-auto-flow: column;
gap: 2px;
padding-inline: var(--spacing-4);
}
.ui-pick-menu__caret {
font-size: var(--text-caption);
line-height: 1;
}
.ui-pick-menu--multi > .ui-badge[hidden] {
display: none;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
:is(.ui-row-menu, .ui-row-menu__btn, .ui-row-menu__item)[hidden] {
display: none !important;
+115 -30
View File
@@ -12,7 +12,8 @@
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import type { IconName } from "./ui_template_icons";
import { createBadge } from "./ui_template_badge";
import { icon, type IconName } from "./ui_template_icons";
import "./ui_template_context_menu.css";
import { showConfirmDialog } from "./ui_template_modal";
@@ -299,58 +300,125 @@ export function createRowMenu(items: RowMenuItem[]): HTMLElement {
}
/* =============================================================================
* 부품 「아이콘 고르기 메뉴」(PLAN 626-1 · 요구 530 · 지도 도구줄 단추 하나 + 아래 판)
* 꼴 = 아이콘 단추(32 · 고른 것 있으면 눌림) → 아래 판 = 항목 하나만 고름(✓) + 맨 끝 「끔」
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 disabled)
* 부품 「고르기 메뉴」(PLAN 626-1 · 634-1 · 요구 530 · 546 · 지도 도구줄 단추 하나 + 아래 판)
* 꼴 = 하나(기본) — 아이콘 단추(32) → 아래 판 = 항목 하나만 고름(✓) · 누르면 닫힘 · `offLabel` 이면 맨 끝 「끔」 줄 + 고르면 눌림
* 여럿(`multi`) — 아이콘 + 켠 수 배지(0 = 없음) + ▾ · 판 = 켬끔 줄(✓) · 눌러도 안 닫힘 · 하나라도 켜면 눌림
* 줄 = `icon` · `title`(마우스 글) · `disabled`(흐림) — 바꿈은 `setItem(value, { checked, disabled, title })`
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래 · 밀기 · 창 크기 바뀌면 닫힘
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 · 줄 disabled)
* 쓰는 법 = `createPickMenu({ icon: "compass", title, items: [{ value, label }], offLabel: "끔", onPick })`
* → { root, set(value | null), value() } · `onPick` 은 누를 때만(set 은 안 부름)
* → { root, set(value | null), value(), values(), setItem() } · `onPick` 은 누를 때만(set · setItem 은 안 부름)
* 여럿 = `createPickMenu({ multi: true, icon, title, items: [{ value, label, icon }], onPick: (value) => 켬끔 })`
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)
* ========================================================================== */
export interface PickMenuItem {
value: string;
label: string;
title?: string;
icon?: IconName;
disabled?: boolean;
}
export interface PickMenu {
root: HTMLButtonElement;
/** 하나 — 고름 바꿈 · 여럿 — 그 하나만 켬(null = 모두 끔). */
set: (value: string | null) => void;
value: () => string | null;
/** 켠(고른) 줄 값들. */
values: () => string[];
/** 줄 하나 고침 — 켬 · 막힘 · 마우스 글. */
setItem: (
value: string,
patch: { checked?: boolean; disabled?: boolean; title?: string },
) => void;
}
export function createPickMenu(opts: {
icon: IconName;
title: string;
items: Array<{ value: string; label: string; title?: string }>;
offLabel: string;
items: PickMenuItem[];
offLabel?: string;
multi?: boolean;
onPick: (value: string | null) => void;
}): PickMenu {
let current: string | null = null;
const root = createIconButton({ icon: opts.icon, title: opts.title, size: 32, pressed: false });
const multi = !!opts.multi;
const root = createIconButton({
icon: opts.icon,
title: opts.title,
size: 32,
pressed: multi || opts.offLabel ? false : undefined,
});
root.setAttribute("aria-haspopup", "menu");
root.setAttribute("aria-expanded", "false");
const badge = createBadge("0", "accent", { kind: "count", size: 18 });
if (multi) {
root.classList.add("ui-pick-menu--multi");
badge.hidden = true;
const caret = Object.assign(document.createElement("span"), { textContent: "▾" });
caret.className = "ui-pick-menu__caret";
root.append(badge, caret);
}
const pop = document.createElement("div");
pop.className = "ui-row-menu__pop ui-pick-menu__pop";
pop.setAttribute("role", "menu");
pop.hidden = true;
const rows = [...opts.items, { value: "", label: opts.offLabel, title: undefined }].map(
(item) => {
const b = document.createElement("button");
b.type = "button";
b.className = "ui-row-menu__item";
b.textContent = item.label;
if (item.title) b.title = item.title;
b.dataset.value = item.value;
b.setAttribute("role", "menuitemradio");
b.addEventListener("click", () => {
setOpen(false);
set(item.value || null);
opts.onPick(current);
});
pop.append(b);
return b;
},
);
const all: PickMenuItem[] = [
...opts.items,
...(!multi && opts.offLabel ? [{ value: "", label: opts.offLabel }] : []),
];
const rows = all.map((item) => {
const b = document.createElement("button");
b.type = "button";
b.className = "ui-row-menu__item";
if (item.icon) b.append(icon(item.icon, 18));
b.append(Object.assign(document.createElement("span"), { textContent: item.label }));
if (item.title) b.title = item.title;
b.disabled = !!item.disabled;
b.dataset.value = item.value;
b.setAttribute("role", multi ? "menuitemcheckbox" : "menuitemradio");
b.addEventListener("click", () => {
if (multi) {
mark(b, b.getAttribute("aria-checked") !== "true");
return opts.onPick(item.value);
}
setOpen(false);
set(item.value || null);
opts.onPick(current);
});
pop.append(b);
return b;
});
const rowOf = (value: string): HTMLButtonElement | undefined =>
rows.find((row) => row.dataset.value === value);
const values = (): string[] =>
rows
.filter((row) => row.getAttribute("aria-checked") === "true")
.map((row) => row.dataset.value!);
let current: string | null = null;
/** 단추 눌림 · 배지 — 줄 켬에 맞춤. */
function paint(): void {
if (!multi) {
if (opts.offLabel) root.setAttribute("aria-pressed", String(current !== null));
return;
}
const count = values().length;
root.setAttribute("aria-pressed", String(count > 0));
badge.textContent = String(count);
badge.hidden = count === 0;
}
function mark(row: HTMLButtonElement, checked: boolean): void {
row.setAttribute("aria-checked", String(checked));
paint();
}
const entry: MenuEntry = {
close: () => setOpen(false),
contains: (target) => target instanceof Node && (root.contains(target) || pop.contains(target)),
};
/** 밀기(도구줄 · 쪽) · 창 크기 = 판이 단추에서 떨어짐 → 닫음(판 안 밀기는 그대로). */
const onShift = (event: Event): void => {
if (!(event.target instanceof Node && pop.contains(event.target))) setOpen(false);
};
function setOpen(open: boolean): void {
if (open) {
document.body.append(pop);
@@ -361,6 +429,11 @@ export function createPickMenu(opts: {
left: `${at.left}px`,
right: "auto",
});
window.addEventListener("resize", onShift);
document.addEventListener("scroll", onShift, true);
} else {
window.removeEventListener("resize", onShift);
document.removeEventListener("scroll", onShift, true);
}
pop.hidden = !open;
root.setAttribute("aria-expanded", String(open));
@@ -370,14 +443,26 @@ export function createPickMenu(opts: {
}
function set(value: string | null): void {
current = value;
root.setAttribute("aria-pressed", String(value !== null));
for (const row of rows)
row.setAttribute("aria-checked", String((row.dataset.value || null) === value));
paint();
}
set(null);
root.addEventListener("click", (e) => {
e.stopPropagation();
setOpen(pop.hidden === true);
});
return { root, set, value: () => current };
return {
root,
set,
value: () => current,
values,
setItem(value, patch) {
const row = rowOf(value);
if (!row) return;
if (patch.disabled !== undefined) row.disabled = patch.disabled;
if (patch.title !== undefined) row.title = patch.title;
if (patch.checked !== undefined) mark(row, patch.checked);
},
};
}
+11
View File
@@ -158,6 +158,17 @@ const PATHS = {
"M16 5l3 3l-2 1l4 4l-3 1l4 4h-9|M15 21l0 -3|M8 13l-2 -2|M8 12l2 -2|M8 21v-13|M5.824 16a3 3 0 0 1 -2.743 -3.69a3 3 0 0 1 .304 -4.833a3 3 0 0 1 4.615 -3.707a3 3 0 0 1 4.614 3.707a3 3 0 0 1 .305 4.833a3 3 0 0 1 -2.919 3.695h-4z",
plant:
"M7 15h10v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2z|M12 9a6 6 0 0 0 -6 -6h-3v2a6 6 0 0 0 6 6h3|M12 11a6 6 0 0 1 6 -6h3v1a6 6 0 0 1 -6 6h-3|M12 15l0 -6",
// 634-1 — 지도 도구 · 겹(겹친 셋 대신 · 1m 등고선 · 절성토선 · 지질구조선 · 스냅 간격)
contours:
"M10 12a2 1.6 0 1 0 4 0a2 1.6 0 1 0 -4 0|M6.5 12a5.5 4.2 0 1 0 11 0a5.5 4.2 0 1 0 -11 0|M3 12a9 7.5 0 1 0 18 0a9 7.5 0 1 0 -18 0",
cutFill: "M10 4l-1.5 16|M14 4l1.5 16|M6 4l-3.5 16|M18 4l3.5 16",
lineament: "M3 18l3 -4|M8 11.5l2 -3|M12 7l2 3|M16 12.5l2 -3|M20 6.5l1 -1.5",
spacing: "M4 5v14|M20 5v14|M8 12h8|M8 12l2 -2|M8 12l2 2|M16 12l-2 -2|M16 12l-2 2",
// 634-1 — 지도 겹 묶음(수계망도 · 도로 · 토지·건물 · 제한·재해)
streams: "M12 21v-7|M12 14l-5 -7|M12 14l6 -9|M8.8 9.5l-4.8 -1.5|M15.6 8.6l4.4 -0.6",
signpost: "M12 21v-11|M12 5v-2|M5 5h11l3 2.5l-3 2.5h-11z|M9 21h6",
parcelHouse: "M3 21h18|M5 21v-9l7 -6l7 6v9|M10 21v-5h4v5",
ban: "M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M5.7 5.7l12.6 12.6",
} as const;
export type IconName = keyof typeof PATHS;
+30
View File
@@ -62,8 +62,38 @@
display: grid;
}
/* 한 행 두 영역(634-1) — 왼쪽 겹 줄 · 오른쪽 도구줄 · 영역마다 자기 가로 밀기(min-width 0) ·
왼쪽은 행 반까지 제 폭 · 모자라면 오른쪽이 먼저 밀림 · [?] 판은 행 아래 · 빈 자리는 지도 조작 통과 */
.ui-map-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
min-width: 0;
pointer-events: none;
}
.ui-map-row__line {
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
width: 100%;
min-width: 0;
}
.ui-map-row__line > .ui-map-tools {
flex: 0 1 auto;
min-width: 0;
}
.ui-map-row__line > .ui-map-tools:first-child {
flex-shrink: 0;
max-width: 50%;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게 */
.ui-map-tools[hidden],
.ui-map-row[hidden],
.ui-map-tools__hint[hidden] {
display: none !important;
}
+38 -23
View File
@@ -10,6 +10,7 @@
* hint: { lines: ["휠 = 확대"] } }) → { root, bar, hint, button(act) }
* 페이지 문구가 다르면 `mapToolButton("measure", { title })` 를 묶음에 그대로 끼움 · 고르기 · 칩도 요소로 끼움
* 누름 동작은 페이지가 붙임(`onClick` 또는 `[data-act]` 로 찾아 붙임) · 자리는 페이지 CSS(root)
* 한 행 두 영역 = `createMapToolRow(겹 줄 root, 도구줄 root)`(634-1) · 막힘 글 = `mapToolTip(단추, 까닭)`
* 대신하는 옛 변종 = B05 `b05-route__view-controls` 글자 유리 단추 줄 · `b05-routeedit__hint` 안내 판 · D03 `tool()` 지역 함수
* ========================================================================== */
@@ -20,29 +21,19 @@ import "./ui_template_map_tools.css";
/** 단추 목록 — 차례 = 도구줄 차례 · toggle = 켬끔 단추(aria-pressed) */
export const MAP_TOOLS = {
undo: { icon: "undo", title: "되돌리기 (Ctrl+Z)" },
redo: { icon: "redo", title: "다시하기 (Ctrl+Y)" },
view3d: { icon: "cube", title: "3D 보기" },
"rotate-ccw": { icon: "rotateCcw", title: "반시계로 돌리기" },
"rotate-cw": { icon: "rotateCw", title: "시계로 돌리기" },
"contour-minor": { icon: "ripple", title: "1m 등고선", toggle: true },
undo: { icon: "undo", title: "되돌리기" },
redo: { icon: "redo", title: "다시하기" },
view3d: { icon: "cube", title: "3D" },
"rotate-ccw": { icon: "rotateCcw", title: "좌 회전" },
"rotate-cw": { icon: "rotateCw", title: "우 회전" },
"contour-minor": { icon: "contours", title: "등고선 1m", toggle: true },
stations: { icon: "tallymarks", title: "측점", toggle: true },
slopes: { icon: "percentage", title: "기울기", toggle: true },
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
"expected-draw": {
icon: "pencil",
title:
"예정노선 변경 — 켜면 예정노선이 맨 위 · 예정노선 페이지와 같은 긋기 · 고르기 · 고치기 → 평면선을 예정노선 따라 통째 다시 추적 (켠 동안 계획선 · 구조물은 안 집힘)",
toggle: true,
},
"expected-original": {
icon: "lineDashed",
title:
"예정노선(원본) — 파일로 올린 예정노선을 옅은 점선으로 보이기 · 숨기기 (고친 예정노선과 다를 때만 보임)",
toggle: true,
},
slopes: { icon: "percentage", title: "길이·기울기", toggle: true },
footprint: { icon: "cutFill", title: "절성토선", toggle: true },
measure: { icon: "ruler", title: "길이측정", toggle: true },
"station-snap": { icon: "magnet", title: "스냅", toggle: true },
"expected-draw": { icon: "pencil", title: "예정노선 변경", toggle: true },
"expected-original": { icon: "lineDashed", title: "예정노선 원본", toggle: true },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
@@ -68,9 +59,18 @@ export function mapToolButton(act: MapToolAct, opts: MapToolButtonOptions = {}):
onClick: opts.onClick,
});
button.dataset.act = act;
button.dataset.tip = opts.title ?? tool.title;
return button;
}
/** 마우스 글(요구 546) — 평소 제목만 · 막힘이면 「제목 — 까닭」 · 제목 = `data-tip`(없으면 지금 글). */
export function mapToolTip(el: HTMLElement, why?: string | null): void {
el.dataset.tip ??= el.title;
const text = why ? `${el.dataset.tip} — ${why}` : el.dataset.tip;
el.title = text;
el.setAttribute("aria-label", text);
}
export interface MapToolsOptions {
/** 묶음들 — 사이에 나눔선 · act 이름 또는 페이지가 만든 요소 */
groups: (MapToolAct | HTMLElement)[][];
@@ -119,7 +119,7 @@ export function createMapTools(opts: MapToolsOptions): MapTools {
let hint: HTMLElement | null = null;
if (opts.hint) {
const { lines, onToggle } = opts.hint;
const toggle = createIconButton({ icon: "help", title: "조작 도움말 펼치기 · 접기", size: 32 });
const toggle = createIconButton({ icon: "help", title: "도움말", size: 32 });
toggle.dataset.act = "hint-toggle";
const panel = document.createElement("div");
panel.className = "ui-map-tools__hint";
@@ -154,3 +154,18 @@ export function createMapTools(opts: MapToolsOptions): MapTools {
button: (act) => root.querySelector<HTMLButtonElement>(`[data-act="${act}"]`),
};
}
/** 한 행 두 영역(634-1 · 요구 546) — 왼쪽(지도 겹) · 오른쪽(도구) · 영역마다 자기 가로 밀기 · 모자라면 오른쪽 먼저 밀림 ·
* [?] 안내 판은 행 아래로 옮김 · 자리(위 · 왼쪽 · 오른쪽)는 페이지 CSS(root). */
export function createMapToolRow(left: HTMLElement, right: HTMLElement): HTMLElement {
const root = document.createElement("div");
root.className = "ui-map-row";
const line = document.createElement("div");
line.className = "ui-map-row__line";
line.append(left, right);
const hints = [left, right].flatMap((area) =>
Array.from(area.querySelectorAll<HTMLElement>(":scope > .ui-map-tools__hint")),
);
root.append(line, ...hints);
return root;
}
+26 -2
View File
@@ -10,7 +10,8 @@ import { createFloatPanel } from "./ui_template_float_panel";
import { createFormGrid, createPageFrame } from "./ui_template_general_layout";
import { createInputField } from "./ui_template_input";
import { createListRow } from "./ui_template_list_row";
import { createMapTools, mapToolButton } from "./ui_template_map_tools";
import { createPickMenu } from "./ui_template_context_menu";
import { createMapToolRow, createMapTools, mapToolButton } from "./ui_template_map_tools";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import { createPanelHead } from "./ui_template_panel_head";
import { createPublicFrame } from "./ui_template_public_layout";
@@ -237,7 +238,30 @@ function mapTools(): HTMLElement {
],
});
tools.button("contour-minor")!.disabled = true;
return tools.root;
// 634-1 한 행 두 영역 — 왼쪽 겹 줄(단독 + 묶음 메뉴) · 오른쪽 도구줄
const water = createPickMenu({
multi: true,
icon: "streams",
title: "수계망도",
onPick: () => {},
items: [
{ value: "stream", label: "세류", icon: "droplet" },
{ value: "water", label: "하천", icon: "ripple" },
],
});
water.setItem("stream", { checked: true });
const layers = createMapTools({
label: "지도 겹",
groups: [
[
createIconButton({ icon: "satellite", title: "위성사진", size: 32, pressed: true }),
water.root,
],
],
});
const row = createMapToolRow(layers.root, tools.root);
row.style.width = "100%";
return row;
}
/** 열쇠 → 견본(상태별 · null = 그 상태 없음) */
+15 -2
View File
@@ -31,7 +31,7 @@ import { createText } from "./ui_template_text";
import { createToggle } from "./ui_template_toggle";
// 552-3 묶음 부품
import { createBanner } from "./ui_template_banner";
import { createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
import { createDrop } from "./ui_template_drop";
import { buildModal, confirmPanel } from "./ui_template_modal";
import { settingCard, settingRow, valueText } from "./ui_template_setting_row";
@@ -134,6 +134,18 @@ const gridRows = (): Cell[][] => [
[{ content: "암", head: true }, "3.10", "m³"],
[{ content: "쌓기", colSpan: 2, head: true }, "8.05", "m³"],
];
/** 634-1 견본 — 고르기 메뉴 하나(추천 노선 꼴) · 여럿(지도 겹 묶음 · 켠 수 배지 · 막힌 줄) */
const pickDemo = (s: KitState): HTMLElement | null => {
if (s !== "default" && s !== "disabled") return null;
const one = createPickMenu({ icon: "compass", title: "추천노선", items: [{ value: "a", label: "노선 가" }, { value: "b", label: "노선 나" }], offLabel: "끔", onPick: noop });
const many = createPickMenu({ multi: true, icon: "streams", title: "수계망도", onPick: noop,
items: [{ value: "stream", label: "세류", icon: "droplet" }, { value: "water", label: "하천", icon: "ripple" }, { value: "bands", label: "계류100m", icon: "borderOuter", disabled: true, title: "계류100m — 지형 받기 뒤" }] });
one.set("a");
many.setItem("stream", { checked: true });
many.setItem("water", { checked: true });
if (s === "disabled") [one.root, many.root].forEach((b) => (b.disabled = true));
return row(one.root, many.root);
};
const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => {
const menu = createRowMenu(items);
menu.classList.add("is-open");
@@ -322,6 +334,7 @@ export const KIT_PARTS: KitPart[] = [
{ key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯(28) · 펼친 판 · 위험 항목(빨강 + 확인창) · 막힌 항목", file: "ui_template_context_menu.ts",
demo: (s) => { if (s === "error") return null; if (s === "default") return createRowMenu(KIT_MENU); if (s === "hover") { const m = createRowMenu(KIT_MENU); markState(m.querySelector<HTMLElement>(".ui-row-menu__btn")!, s); return m; }
return openMenu(s, s === "disabled" ? [{ label: "내보내기", onClick: noop, disabled: true }, ...KIT_MENU] : KIT_MENU); } },
{ key: "pick-menu", name: "고르기 메뉴(하나 · 여럿)", group: "compound", kinds: "아이콘 단추 32 → 아래 판 · 하나(✓ · 누르면 닫힘 · 끔 줄) · 여럿(켬끔 ✓ · 안 닫힘 · 켠 수 배지 · ▾) · 줄 아이콘 · 막힌 줄 흐림", file: "ui_template_context_menu.ts", demo: pickDemo },
{ key: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법 · 작은 꼴", file: "ui_template_empty.ts",
demo: (s) => (s === "default" ? col(createEmpty({ icon: "folder", text: "아직 프로젝트가 없음", hint: "새 프로젝트를 만들어 시작", action: createButton({ label: "새 프로젝트", onClick: noop }), onGuide: noop }), createEmpty({ text: "고른 측점 없음", small: true })) : null) },
{ key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통(720) · 넓음 · 폭 단 24rem · 40rem · 960 · 넓은 중간 1100 × 680 · 긴 제목 = 한 줄 말줄임 · 확인 · 위험 확인 · 글 묻기(prompt 대신)", file: "ui_template_modal.ts",
@@ -370,7 +383,7 @@ export const KIT_PARTS: KitPart[] = [
demo: FRAME_DEMOS["drawer"] },
{ key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts",
demo: FRAME_DEMOS["toolbar"] },
{ key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만", file: "ui_template_map_tools.ts",
{ key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만 · 한 행 두 영역(겹 · 도구 · 영역마다 밀기)", file: "ui_template_map_tools.ts",
demo: FRAME_DEMOS["map-tools"] },
{ key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts",
demo: FRAME_DEMOS.steps },