Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) # 다른 연도는 안 샘
|
||||
|
||||
# 다시 보내기
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) # 노선 그대로
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = 그 상태 없음) */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user