From 2265ac4f7c1f54d8327d64ed1dcdde0e540ac48e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:26:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(=EB=B6=80=ED=92=88):=20634-1=20?= =?UTF-8?q?=EC=97=AC=EB=9F=BF=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=C2=B7=20=ED=95=9C=20=ED=96=89=20=EB=91=90=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=C2=B7=20=EB=A7=89=ED=9E=98=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=20=EC=95=84=EC=9D=B4=EC=BD=98(=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createPickMenu multi — menuitemcheckbox · 눌러도 안 닫힘 · values() · 줄 icon · disabled · title · setItem · 단추 ▾ + 켠 수 배지(0 = 없음) · 하나라도 켜면 눌림 · 밀기 · 창 크기 바뀌면 닫힘 · offLabel 없으면 끔 줄 · 눌림 없음 - createMapToolRow — 그룹1 왼쪽(행 반까지) · 그룹2 오른쪽 · 영역마다 min-width 0 + 자기 가로 밀기 · [?] 판은 행 아래 - mapToolTip — 평소 제목만 · 막힘 때만 「제목 — 까닭」 · MAP_TOOLS 글 = 표 호버제목 - 아이콘 — 묶음 넷(streams · signpost · parcelHouse · ban) · 겹친 셋 대신(contours · cutFill · lineament) · 스냅 간격 spacing - 배지 count · accent 덧 · 부품 모음 견본 「고르기 메뉴(하나 · 여럿)」 · 지도 도구줄 한 행 견본 - 시험 568_1 고침 — 예정노선 둘 줄이 한 줄 꼴이 되어 목록에 잡힘 · 새 지킴 634_1 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_568_1_map_tools.py | 4 + resources/tester/test_634_1_pick_menu_row.py | 68 +++++++++ ui_template/ui_template_badge.css | 6 + ui_template/ui_template_context_menu.css | 26 ++++ ui_template/ui_template_context_menu.ts | 145 +++++++++++++++---- ui_template/ui_template_icons.ts | 11 ++ ui_template/ui_template_map_tools.css | 30 ++++ ui_template/ui_template_map_tools.ts | 61 +++++--- ui_template/ui_template_ui_kit_frames.ts | 28 +++- ui_template/ui_template_ui_kit_parts.ts | 17 ++- 10 files changed, 339 insertions(+), 57 deletions(-) create mode 100644 resources/tester/test_634_1_pick_menu_row.py diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py index 9cfd9653f..2fbc11742 100644 --- a/resources/tester/test_568_1_map_tools.py +++ b/resources/tester/test_568_1_map_tools.py @@ -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", } diff --git a/resources/tester/test_634_1_pick_menu_row.py b/resources/tester/test_634_1_pick_menu_row.py new file mode 100644 index 000000000..c16fb31db --- /dev/null +++ b/resources/tester/test_634_1_pick_menu_row.py @@ -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 diff --git a/ui_template/ui_template_badge.css b/ui_template/ui_template_badge.css index 2ffde90d0..f3d6c8166 100644 --- a/ui_template/ui_template_badge.css +++ b/ui_template/ui_template_badge.css @@ -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); +} diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 8565c0dc4..bab8d247e 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -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; diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index a6fc3719a..db8dbb558 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -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); + }, + }; } diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 1648672f5..58d8a91ab 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -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; diff --git a/ui_template/ui_template_map_tools.css b/ui_template/ui_template_map_tools.css index debd97581..7825dbdaf 100644 --- a/ui_template/ui_template_map_tools.css +++ b/ui_template/ui_template_map_tools.css @@ -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; } diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index a4c24aa70..4fa983ccf 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -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; 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(`[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(":scope > .ui-map-tools__hint")), + ); + root.append(line, ...hints); + return root; +} diff --git a/ui_template/ui_template_ui_kit_frames.ts b/ui_template/ui_template_ui_kit_frames.ts index b1e9558a3..f86d00964 100644 --- a/ui_template/ui_template_ui_kit_frames.ts +++ b/ui_template/ui_template_ui_kit_frames.ts @@ -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 = 그 상태 없음) */ diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index f1c28dd1d..f5743ffbc 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -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(".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 }, From f6708c6818b5027243762d75522ddf804b82bbb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:28:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(637-1):=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20LAS=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20=C2=B7?= =?UTF-8?q?=20=EC=A7=93=EA=B8=B0=20=EA=B3=B5=EC=9A=A9=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20common=5Futil=5Flidar=5Fupload=20=E2=80=94=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EC=98=AE=EA=B2=A8=20=EB=86=93=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EA=B0=88=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A7=93=EA=B8=B0(maps=3DFalse)=20=C2=B7=20=EB=92=A4?= =?UTF-8?q?=20=EC=9D=BC=20=C2=B7=20=EC=A7=84=ED=96=89=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20DB=20=EB=84=A3=EA=B8=B0/=EA=B3=A0=EC=B9=98=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C(keep=20=3D=20=EA=B0=88=EC=9D=8C=20=EB=95=8C=20id=20?= =?UTF-8?q?=C2=B7=20=ED=99=95=EC=A0=95=20=C2=B7=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=82=B4=EB=A6=BC)=20=C2=B7=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=EC=9D=80=20=EA=B7=B8=EA=B1=B8=20?= =?UTF-8?q?=EB=B6=80=EB=A6=84(=EB=8F=99=EC=9E=91=20=C2=B7=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B7=B8=EB=8C=80=EB=A1=9C)=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=ED=82=B4=20=EC=8B=9C=ED=97=98=20test=5F637=5Flidar(=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py | 101 +------- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 73 +----- common_util/common_util_lidar_upload.py | 265 ++++++++++++++++++++ resources/tester/test_637_lidar.py | 157 ++++++++++++ 4 files changed, 440 insertions(+), 156 deletions(-) create mode 100644 common_util/common_util_lidar_upload.py create mode 100644 resources/tester/test_637_lidar.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py b/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py index c50f3d8c3..42c34a3fe 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py @@ -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) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 676edafa5..74dfc0d70 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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) diff --git a/common_util/common_util_lidar_upload.py b/common_util/common_util_lidar_upload.py new file mode 100644 index 000000000..a78e57d15 --- /dev/null +++ b/common_util/common_util_lidar_upload.py @@ -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 diff --git a/resources/tester/test_637_lidar.py b/resources/tester/test_637_lidar.py new file mode 100644 index 000000000..7679caafa --- /dev/null +++ b/resources/tester/test_637_lidar.py @@ -0,0 +1,157 @@ +"""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] From 83f3bc0a5c3a4ad5a86579ed6fb1c92b38f07b37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:31:17 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(638-2):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=85=B8=EC=84=A0=20=EC=84=B8=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=85=88=20=E2=80=94=20=EC=84=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=C2=B7=20=EB=81=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=82=98=EB=88=94=20=C2=B7=20=EC=9D=B4=EC=9B=83=20=ED=95=A9?= =?UTF-8?q?=EC=B9=A8=20=C2=B7=20=EA=B3=A0=EB=A5=BC=20=EC=84=B8=ED=8A=B8=20?= =?UTF-8?q?=C2=B7=20[=EC=9E=87=EA=B8=B0](=EC=9A=94=EA=B5=AC=20547=20=C2=B7?= =?UTF-8?q?=20548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 logic/D03_PlanAnalysis_Group.ts: assignSet(고른 구간 끝 나눔 → 세트 → 같은 선 이웃 같은 세트 하나 · 앞 id) · mergeSets(서버 Network.derive 짝) · setChoices(있는 세트 + 해마다 새 번호) · joinEnds(닿음 바로 · 떨어짐 곧은 토막 덧 = 앞 선 세트) - Plan.ts 세 곳: PlanPiece.set · 새 선 처음 세트(사업연도 시작 해 새 번호 · 갈래 = 붙은 자리 세트 · 옛 꼴 = 그 해 최댓값 + 1) · 이음 합침 비교 = 세트 · 661 줄 그대로 - Select.ts 끌어 고르기 시험 조각에 set 칸(타입 맞춤) - Api.ts 꼴: derived.sets(PlanSetView · 638-1 서버 셈) - 시험 test_638_group.py · 거울 사례 fixtures/d03_638_set_cases.json(sub3 638-1 과 같은 파일) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 15 +- .../logic/D03_PlanAnalysis_Group.ts | 169 ++++++ .../logic/D03_PlanAnalysis_Plan.ts | 50 +- .../logic/D03_PlanAnalysis_Select.ts | 9 +- .../tester/fixtures/d03_638_set_cases.json | 533 ++++++++++++++++++ resources/tester/test_638_group.py | 298 ++++++++++ 6 files changed, 1047 insertions(+), 27 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts create mode 100644 resources/tester/fixtures/d03_638_set_cases.json create mode 100644 resources/tester/test_638_group.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 01e4352ee..d0219ef8a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -3,7 +3,7 @@ * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 · * 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) · - * 라이다 지형(624-4 · LAS 올리기 · 지은 지형 · 높이 격자) · + * 라이다 지형(624-4 · LAS 올리기 · 지은 지형 · 높이 격자) · 노선 세트 꼴(638 · 조각 `set` · `derived.sets`) · * 지도 겹 받기 = 공용 `A00_Common/map/map_layer_api`(624-3 · 다시 내놓음). * ========================================================================== */ @@ -82,12 +82,25 @@ export interface ServerNetwork { pieces: PlanPiece[]; } +/** 서버가 [저장] · 읽기 때 센 세트(638-1) — 끊김 · 갈래 경고는 이 값으로만(화면 셈 없음). */ +export interface PlanSetView { + key: string; + year: number; + no: number; + parts: number; + branch: boolean; + length_m: number; + pieces: string[]; +} + export interface PlanNetworkView { base: PlanBaseView; network: ServerNetwork | null; derived: { lines: Array<{ id: string; length_m: number; stations: number[] }>; pieces: Array; + /** 세트(638) — 열쇠 「연도-번호」 · 덩이 수(`parts` > 1 = 끊김) · 세트 안 갈래 · 조각 id. */ + sets: PlanSetView[]; } | null; /** 사업연도 시작(있으면). */ year_start?: number | null; diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts new file mode 100644 index 000000000..511dc8436 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts @@ -0,0 +1,169 @@ +/* ============================================================================= + * D03_PlanAnalysis_Group.ts + * D 예정노선 노선 세트(PLAN 638-2 · 요구 547 · 548) — 세트 = 「연도-번호」(2027-1) · 같은 번호 = 갈라져 있어도 같은 노선. + * 세트 지정(고른 구간 끝에서 조각 나눔 → 세트 바꿈 → 같은 선 이웃 같은 세트 하나로) · 고를 세트 목록 · + * [잇기](두 선 끝 · 닿으면 바로 · 떨어지면 곧은 토막 덧 · 이은 토막 = 앞 선 세트). + * 새 선 처음 세트 · 갈래 · 이어 긋기 = `D03_PlanAnalysis_Plan.normalizePieces`. + * ⚠ 짝 = `D03_PlanAnalysis_Network.derive` 세트 합침(서버 [저장] 정본) — 거울 시험 638(`fixtures/d03_638_set_cases.json`). + * 끊김 · 갈래 경고 셈은 서버 한 곳(여기 없음). DOM · fetch · sessionStorage 없음. + * ========================================================================== */ + +import { + appendVertex, + attachHead, + nextSet, + normalizePieces, + reverseLine, + samePoint, + splitPiece, + type PlanNetwork, + type PlanPiece, +} from "./D03_PlanAnalysis_Plan"; + +/** 세트 — 연도 · 그 해 번호(1 부터). */ +export interface PieceSet { + year: number; + set: number; +} + +/** 이웃 조각이 맞닿았다고 볼 틈(m) — 서버 합침과 같음(1 cm). */ +const TOUCH_M = 0.01; + +/** 세트 열쇠 「연도-번호」(업체 지정 · B 받기 단위). */ +export const setKey = (s: PieceSet): string => `${s.year}-${s.set}`; + +export const sameSet = (a: PieceSet, b: PieceSet): boolean => a.year === b.year && a.set === b.set; + +/** 같은 선 이웃(from_m 차례 · 맞닿음) 같은 세트 = 하나(앞 조각 id · 사이 마크 걷음) · 다른 선끼리 안 합침 · + * `lines` = 이 선만(없으면 모두) · 결과 = 선 차례 · from_m 차례. */ +export function mergeSets(network: PlanNetwork, lines?: ReadonlySet): PlanNetwork { + const pieces: PlanPiece[] = []; + for (const line of network.lines) { + const own = network.pieces + .filter((piece) => piece.line === line.id) + .sort((a, b) => a.from_m - b.from_m); + for (const piece of own) { + const last = pieces[pieces.length - 1]; + const join = + (!lines || lines.has(line.id)) && + last?.line === line.id && + sameSet(last, piece) && + Math.abs(last.to_m - piece.from_m) <= TOUCH_M; + if (join) pieces[pieces.length - 1] = { ...last, to_m: piece.to_m }; + else pieces.push({ ...piece }); + } + } + return { ...network, pieces }; +} + +/** 선 위 구간(m). */ +export interface LineRange { + line: string; + from_m: number; + to_m: number; +} + +/** 세트 지정 — 고른 구간 끝에서 조각을 나눔(끝 · 경계와 1 m 안이면 그 경계) → 가운데가 구간 안인 조각 = `target` → + * 고친 선 이웃 같은 세트 하나로. 연도를 바꾸면 `target` = 그 해 새 세트(`setChoices`). */ +export function assignSet( + network: PlanNetwork, + ranges: ReadonlyArray, + target: PieceSet, +): PlanNetwork { + let next = network; + for (const range of ranges) + for (const at of [range.from_m, range.to_m]) next = splitPiece(next, range.line, at); + const inside = (piece: PlanPiece): boolean => + ranges.some((range) => { + const mid = (piece.from_m + piece.to_m) / 2; + const [a, b] = [Math.min(range.from_m, range.to_m), Math.max(range.from_m, range.to_m)]; + return range.line === piece.line && mid > a && mid < b; + }); + const pieces = next.pieces.map((piece) => + inside(piece) ? { ...piece, year: target.year, set: target.set } : piece, + ); + return mergeSets({ ...next, pieces }, new Set(ranges.map((range) => range.line))); +} + +/** 고를 세트 — 있는 세트 + 해마다 「새 세트」(`fresh` · 번호 = 그 해 최댓값 + 1) · 연도 · 번호 차례. */ +export interface SetChoice extends PieceSet { + key: string; + fresh: boolean; +} + +export function setChoices(network: PlanNetwork, years: ReadonlyArray): SetChoice[] { + const have = new Map(); + for (const piece of network.pieces) + if (piece.set > 0) have.set(setKey(piece), { year: piece.year, set: piece.set }); + const allYears = new Set([...years, ...[...have.values()].map((s) => s.year)]); + const out: SetChoice[] = []; + for (const year of [...allYears].sort((a, b) => a - b)) { + const own = [...have.values()].filter((s) => s.year === year).sort((a, b) => a.set - b.set); + for (const s of own) out.push({ ...s, key: setKey(s), fresh: false }); + const fresh = { year, set: nextSet(network.pieces, year) }; + out.push({ ...fresh, key: setKey(fresh), fresh: true }); + } + return out; +} + +/** 선 끝 — 선 id · 머리(첫 점) 쪽인가. */ +export interface LineEnd { + line: string; + head: boolean; +} + +const indexOf = (network: PlanNetwork, id: string): number => + network.lines.findIndex((line) => line.id === id); + +const endPoint = (network: PlanNetwork, end: LineEnd): [number, number] => { + const v = network.lines[indexOf(network, end.line)].vertices; + return end.head ? v[0] : v[v.length - 1]; +}; + +/** `id` 선 방향 바꿈(갈래 = null). */ +function flip(network: PlanNetwork, id: string): PlanNetwork | null { + const out = reverseLine(network, indexOf(network, id)); + return typeof out === "string" ? null : out; +} + +/** `x` 끝이 꼬리 · `y` 끝이 머리가 되게 돌려 `y` 를 `x` 꼬리에 이음 — 못 하면 null. */ +function joinAt(network: PlanNetwork, x: LineEnd, y: LineEnd): PlanNetwork | null { + let next: PlanNetwork | null = network; + if (x.head) next = flip(next, x.line); + if (next && !y.head) next = flip(next, y.line); + if (!next) return null; + const xn = next.lines[indexOf(next, x.line)].vertices.length; + const yn = next.lines[indexOf(next, y.line)].vertices.length; + const joined = attachHead(next, indexOf(next, y.line)); + const after = joined.lines[indexOf(joined, x.line)]; + // 다른 선 꼬리에 붙거나 갈래가 됨 = 이 두 선 이음이 아님 + return indexOf(joined, y.line) < 0 && after?.vertices.length === xn + yn - 1 ? joined : null; +} + +/** [잇기] — 두 선 끝을 한 선으로 · 떨어져 있으면 `a` 끝에서 `b` 끝까지 곧은 토막을 덧댐(그 토막 = `a` 끝 조각 세트) · + * 이음 자리 두 조각이 같은 세트면 하나 · 못 이음 = 까닭 글. `year` = 새 선 처음 해(`normalizePieces`). */ +export function joinEnds( + network: PlanNetwork, + a: LineEnd, + b: LineEnd, + year: number, +): PlanNetwork | string { + const [ia, ib] = [indexOf(network, a.line), indexOf(network, b.line)]; + if (ia < 0 || ib < 0) return "이을 선이 없습니다."; + if (ia === ib) return "같은 선의 두 끝은 이을 수 없습니다."; + const isBranch = (end: LineEnd): boolean => + end.head && !!network.lines[indexOf(network, end.line)].branch; + if (isBranch(a) || isBranch(b)) return "갈래 첫 점은 붙은 선 위라 이을 수 없습니다."; + let next = network; + const to = endPoint(network, b); + if (!samePoint(endPoint(network, a), to)) { + // `a` 끝에 곧은 토막 — 머리 쪽이면 뒤집어 꼬리에 덧대고 다시 뒤집음(조각이 따라감) + if (a.head) next = flip(next, a.line) ?? next; + next = normalizePieces(appendVertex(next, indexOf(next, a.line), [to[0], to[1]]), year); + if (a.head) next = flip(next, a.line) ?? next; + } + // 덜 뒤집는 쪽 먼저(머리 · 꼬리면 뒤 선 꼬리에 앞 선) + const [x, y] = a.head && !b.head ? [b, a] : [a, b]; + const joined = joinAt(next, x, y) ?? joinAt(next, y, x); + return joined ? normalizePieces(joined, year) : "두 선을 이을 수 없습니다 — 선 끝을 고르세요."; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index d2ba30ff3..74ebe5e27 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -55,13 +55,14 @@ export function nameError(name: string): string | null { return null; } -/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */ +/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도 · 세트 번호(638 열쇠 「연도-번호」 · 셈 `D03_PlanAnalysis_Group`) — 마크 = 조각 경계 · id 는 나눠도 앞 조각이 지님. */ export interface PlanPiece { id: string; line: string; from_m: number; to_m: number; year: number; + set: number; } export interface PlanNetwork { @@ -76,6 +77,9 @@ const SAME_M = 1e-3; const round3 = (value: number): number => Math.round(value * 1000) / 1000; export const samePoint = (a: Vertex, b: Vertex): boolean => segmentLength(a, b) <= SAME_M; +/** 그 해 새 세트 번호 = 있는 번호 최댓값 + 1(638 · 지워도 다른 번호 안 당김). */ +export const nextSet = (pieces: ReadonlyArray, year: number): number => + Math.max(0, ...pieces.filter((p) => p.year === year && p.set > 0).map((p) => p.set)) + 1; /** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ function nextPieceId(pieces: ReadonlyArray): string { @@ -107,8 +111,8 @@ export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork { return { ...network, lines: [...network.lines, line] }; } -/** 새 갈래 처음 해(585-3) = 붙은 자리 구간 해 · 붙은 자리 못 찾음 = null. */ -function branchYear(network: PlanNetwork, line: PlanLine): number | null { +/** 새 갈래 처음 해 · 세트(585-3 · 638) = 붙은 자리 조각 · 못 찾음 = null. */ +function branchPiece(network: PlanNetwork, line: PlanLine): PlanPiece | null { const parent = network.lines.find((other) => other.id === line.branch?.line); if (!parent || !line.branch) return null; const atM = lineLength(parent.vertices.slice(0, line.branch.vertex + 1)); @@ -116,12 +120,12 @@ function branchYear(network: PlanNetwork, line: PlanLine): number | null { network.pieces.find( (piece) => piece.line === parent.id && atM >= piece.from_m - SAME_M && atM <= piece.to_m + SAME_M, - )?.year ?? null + ) ?? null ); } -/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 · - * 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */ +/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 · 세트 = 그 자리 조각 · + * 조각 없는 선 = 한 조각(갈래 = 붙은 자리 세트 · 그 밖 `year` 새 번호) · 연도 · 번호 없는 조각(서버 마크만 · 옛 꼴) = 처음 연도 · 그 해 새 번호. 길이 0 선은 조각 없음. */ export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork { const pieces: PlanPiece[] = []; for (const line of network.lines) { @@ -132,22 +136,17 @@ export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork .sort((a, b) => a.from_m - b.from_m); const starts = own .filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2) - .map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year })); - const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { - id: "", - year: own.length ? year : (branchYear(network, line) ?? year), - }; - const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts]; - cuts.forEach((cut, i) => - pieces.push({ - // id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도 - id: cut.id || nextPieceId([...network.pieces, ...pieces]), - line: line.id, - from_m: cut.from, - to_m: i + 1 < cuts.length ? cuts[i + 1].from : length, - year: Number.isFinite(cut.year) ? cut.year : year, - }), - ); + .map((piece) => ({ ...piece, from_m: round3(piece.from_m) })); + const fresh = (own.length ? null : branchPiece(network, line)) ?? { year, set: NaN }; + const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { ...fresh, id: "" }; + const cuts = [{ ...head, from_m: 0 }, ...starts]; + cuts.forEach((cut, i) => { + const at = Number.isFinite(cut.year) ? cut.year : year; + const set = cut.set > 0 ? cut.set : nextSet([...network.pieces, ...pieces], at); + const id = cut.id || nextPieceId([...network.pieces, ...pieces]); + const to = i + 1 < cuts.length ? cuts[i + 1].from_m : length; + pieces.push({ id, line: line.id, from_m: cut.from_m, to_m: to, year: at, set }); + }); } return { ...network, pieces }; } @@ -297,7 +296,7 @@ export function fromServer( (piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01, ), ) - .map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN })); + .map((mark) => ({ ...mark, id: "", from_m: mark.at_m, to_m: mark.at_m, year: NaN, set: 0 })); return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year); } @@ -497,7 +496,7 @@ export function removeLine(network: PlanNetwork, line: number): PlanNetwork | st }; } -/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 해면 하나로. */ +/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 세트면 하나로. */ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork { const [head, tail] = [network.lines[a], network.lines[b]]; const offset = round3(lineLength(head.vertices)); @@ -521,7 +520,8 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork { !( piece.line === head.id && Math.abs(piece.from_m - offset) < SAME_M && - last?.year === piece.year + last?.year === piece.year && + last.set === piece.set ), ); const lines = network.lines diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts index 4b44d001a..3aaa65471 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -125,7 +125,14 @@ export function segmentsInBox( })), pieces: segs.map((seg, i) => { const [a, b] = segmentPath(network, seg)!; - return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 }; + return { + id: String(i), + line: `s${i}`, + from_m: 0, + to_m: segmentLength(a, b), + year: 0, + set: 0, + }; }), }; return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]); diff --git a/resources/tester/fixtures/d03_638_set_cases.json b/resources/tester/fixtures/d03_638_set_cases.json new file mode 100644 index 000000000..fedcfdca6 --- /dev/null +++ b/resources/tester/fixtures/d03_638_set_cases.json @@ -0,0 +1,533 @@ +{ + "about": "PLAN 638 세트 합침 거울 사례 — 서버 Network.derive · 화면 Group.mergeSets(fromServer 뒤) 같은 답. 입력 = PUT 꼴(lines · marks · pieces) · 기대 = (선 차례, from_m) 차례 조각.", + "year_start": 2027, + "cases": [ + { + "name": "같은 선 이웃 같은 세트 = 하나(앞 id)", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [100, 0] + ], + "branch": null + } + ], + "marks": [ + { + "line": "main", + "at_m": 30 + }, + { + "line": "main", + "at_m": 60 + } + ], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 30, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "main", + "from_m": 30, + "to_m": 60, + "year": 2027, + "set": 1 + }, + { + "id": "p3", + "line": "main", + "from_m": 60, + "to_m": 100, + "year": 2027, + "set": 2 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 60, + "year": 2027, + "set": 1 + }, + { + "id": "p3", + "line": "main", + "from_m": 60, + "to_m": 100, + "year": 2027, + "set": 2 + } + ] + }, + { + "name": "셋 이어 합침 · 앞 조각 다른 세트 그대로", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [100, 0] + ], + "branch": null + } + ], + "marks": [ + { + "line": "main", + "at_m": 20 + }, + { + "line": "main", + "at_m": 50 + }, + { + "line": "main", + "at_m": 80 + } + ], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 20, + "year": 2027, + "set": 1 + }, + { + "id": "p4", + "line": "main", + "from_m": 20, + "to_m": 50, + "year": 2028, + "set": 2 + }, + { + "id": "p2", + "line": "main", + "from_m": 50, + "to_m": 80, + "year": 2028, + "set": 2 + }, + { + "id": "p3", + "line": "main", + "from_m": 80, + "to_m": 100, + "year": 2028, + "set": 2 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 20, + "year": 2027, + "set": 1 + }, + { + "id": "p4", + "line": "main", + "from_m": 20, + "to_m": 100, + "year": 2028, + "set": 2 + } + ] + }, + { + "name": "같은 해 다른 번호 · 다른 해 같은 번호 = 안 합침", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [90, 0] + ], + "branch": null + } + ], + "marks": [ + { + "line": "main", + "at_m": 30 + }, + { + "line": "main", + "at_m": 60 + } + ], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 30, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "main", + "from_m": 30, + "to_m": 60, + "year": 2027, + "set": 2 + }, + { + "id": "p3", + "line": "main", + "from_m": 60, + "to_m": 90, + "year": 2028, + "set": 2 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 30, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "main", + "from_m": 30, + "to_m": 60, + "year": 2027, + "set": 2 + }, + { + "id": "p3", + "line": "main", + "from_m": 60, + "to_m": 90, + "year": 2028, + "set": 2 + } + ] + }, + { + "name": "다른 선 같은 세트 = 안 합침(갈래 · 떨어진 선)", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [50, 0], + [100, 0] + ], + "branch": null + }, + { + "id": "branch-1", + "vertices": [ + [50, 0], + [50, 40] + ], + "branch": { + "line": "main", + "vertex": 1 + } + }, + { + "id": "line-2", + "vertices": [ + [100, 0], + [150, 0] + ], + "branch": null + } + ], + "marks": [], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 100, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "branch-1", + "from_m": 0, + "to_m": 40, + "year": 2027, + "set": 1 + }, + { + "id": "p3", + "line": "line-2", + "from_m": 0, + "to_m": 50, + "year": 2027, + "set": 1 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 100, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "branch-1", + "from_m": 0, + "to_m": 40, + "year": 2027, + "set": 1 + }, + { + "id": "p3", + "line": "line-2", + "from_m": 0, + "to_m": 50, + "year": 2027, + "set": 1 + } + ] + }, + { + "name": "옛 꼴(set 없음) = 선 · from_m 차례로 그 해 1 · 2 …", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [100, 0] + ], + "branch": null + }, + { + "id": "line-1", + "vertices": [ + [0, 50], + [80, 50] + ], + "branch": null + } + ], + "marks": [ + { + "line": "main", + "at_m": 40 + }, + { + "line": "main", + "at_m": 70 + } + ], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 40, + "year": 2027 + }, + { + "id": "p2", + "line": "main", + "from_m": 40, + "to_m": 70, + "year": 2028 + }, + { + "id": "p4", + "line": "main", + "from_m": 70, + "to_m": 100, + "year": 2027 + }, + { + "id": "p3", + "line": "line-1", + "from_m": 0, + "to_m": 80, + "year": 2027 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 40, + "year": 2027, + "set": 1 + }, + { + "id": "p2", + "line": "main", + "from_m": 40, + "to_m": 70, + "year": 2028, + "set": 1 + }, + { + "id": "p4", + "line": "main", + "from_m": 70, + "to_m": 100, + "year": 2027, + "set": 2 + }, + { + "id": "p3", + "line": "line-1", + "from_m": 0, + "to_m": 80, + "year": 2027, + "set": 3 + } + ] + }, + { + "name": "set 빠진 조각 하나 = 그 해 최댓값 + 1", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [100, 0] + ], + "branch": null + } + ], + "marks": [ + { + "line": "main", + "at_m": 50 + } + ], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 50, + "year": 2027, + "set": 3 + }, + { + "id": "p2", + "line": "main", + "from_m": 50, + "to_m": 100, + "year": 2027 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 50, + "year": 2027, + "set": 3 + }, + { + "id": "p2", + "line": "main", + "from_m": 50, + "to_m": 100, + "year": 2027, + "set": 4 + } + ] + }, + { + "name": "최댓값 = 그 해 모든 조각(뒤 선 것 포함)", + "network": { + "lines": [ + { + "id": "main", + "vertices": [ + [0, 0], + [100, 0] + ], + "branch": null + }, + { + "id": "line-1", + "vertices": [ + [0, 50], + [80, 50] + ], + "branch": null + } + ], + "marks": [], + "pieces": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 100, + "year": 2027 + }, + { + "id": "p2", + "line": "line-1", + "from_m": 0, + "to_m": 80, + "year": 2027, + "set": 2 + } + ] + }, + "expect": [ + { + "id": "p1", + "line": "main", + "from_m": 0, + "to_m": 100, + "year": 2027, + "set": 3 + }, + { + "id": "p2", + "line": "line-1", + "from_m": 0, + "to_m": 80, + "year": 2027, + "set": 2 + } + ] + } + ] +} diff --git a/resources/tester/test_638_group.py b/resources/tester/test_638_group.py new file mode 100644 index 000000000..05477b724 --- /dev/null +++ b/resources/tester/test_638_group.py @@ -0,0 +1,298 @@ +"""D03 예정노선 노선 세트 화면 셈(PLAN 638-2 · 요구 547 · 548) — TS `logic/D03_PlanAnalysis_Group.ts` · `Plan.ts` 세트 칸. + +tsc 로 옮겨 Node 로 돌림. +- 거울: `fixtures/d03_638_set_cases.json` 사례마다 fromServer → mergeSets = 기대 조각(서버 `Network.derive` 와 같은 답). +- 세트 지정: 고른 구간 끝에서 나눔 · 되돌려 지정하면 하나로(앞 id) · 경계 1 m 안 = 그 경계 · 두 선 구간. +- 고를 세트: 있는 세트 + 해마다 새 번호(최댓값 + 1) · 지워도 다른 번호 그대로. +- 새 선 = 사업연도 시작 해 새 번호 · 갈래 = 붙은 자리 세트 · 이어 긋기 = 끝 세트 그대로. +- [잇기]: 닿음 · 떨어짐(덧 토막 = 앞 선 세트) · 머리끼리 · 같은 선 · 갈래 첫 점 = 까닭 글. +- 기존 동작: 마크 · 토막 지움 · 방향 바꿈에 세트가 따라감 · 이어 긋기 이음 자리 = 같은 세트만 합침. + +./venv/Scripts/python.exe -m pytest resources/tester/test_638_group.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LOGIC = ROOT / "D03_PlanAnalysis" / "logic" +TS_FILES = [LOGIC / f"D03_PlanAnalysis_{name}.ts" for name in ("Draw", "Plan", "Select", "Group")] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +CASES = Path(__file__).resolve().parent / "fixtures" / "d03_638_set_cases.json" + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as group from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.js"; +import { removeSegment } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js"; +const Y = 2027; +const view = (net) => (typeof net === "string" ? net : { + lines: net.lines.map((l) => ({ id: l.id, vertices: l.vertices, branch: l.branch })), + pieces: net.pieces.map(({ id, line, from_m, to_m, year, set }) => ({ id, line, from_m, to_m, year, set })), +}); +const piece = (id, line, from_m, to_m, year, set) => ({ id, line, from_m, to_m, year, set }); +const line = (id, vertices, branch = null) => ({ id, vertices, branch }); +const out = {}; + +// 거울 +const fixture = JSON.parse(readFileSync(process.argv[3], "utf-8")); +out.mirror = fixture.cases.map((c) => { + const n = c.network; + return view(group.mergeSets(plan.fromServer(n.lines, n.marks, n.pieces, fixture.year_start))).pieces; +}); + +// 세트 지정 +const one = { lines: [line("main", [[0, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 100, Y, 1)] }; +const assigned = group.assignSet(one, [{ line: "main", from_m: 30, to_m: 60 }], { year: Y, set: 2 }); +out.assign = view(assigned); +out.assignBack = view(group.assignSet(assigned, [{ line: "main", from_m: 60, to_m: 30 }], { year: Y, set: 1 })); +out.assignNear = view(group.assignSet(assigned, [{ line: "main", from_m: 0.5, to_m: 30.4 }], { year: 2028, set: 1 })); +const two = { + lines: [line("main", [[0, 0], [100, 0]]), line("line-1", [[0, 50], [100, 50]])], + pieces: [piece("p1", "main", 0, 100, Y, 1), piece("p2", "line-1", 0, 100, Y, 2)], +}; +out.assignTwo = view(group.assignSet(two, [ + { line: "main", from_m: 50, to_m: 100 }, + { line: "line-1", from_m: 0, to_m: 40 }, +], { year: 2028, set: 1 })); +// 마크만 놓인 다른 선은 지정에 안 걷힘 +const marked = plan.splitPiece(two, "line-1", 70); +out.assignKeepsOtherMark = view(group.assignSet(marked, [{ line: "main", from_m: 0, to_m: 20 }], { year: Y, set: 3 })); + +// 고를 세트 +const many = { + lines: [line("main", [[0, 0], [300, 0]])], + pieces: [ + piece("p1", "main", 0, 100, Y, 1), piece("p2", "main", 100, 200, Y, 3), piece("p3", "main", 200, 300, 2028, 1), + ], +}; +out.choices = group.setChoices(many, [2027, 2028, 2029]).map((c) => [c.key, c.fresh]); +const dropped = plan.removeMark(many, "main", 100, 0.1); // 2027-3 사라짐 = 다른 번호 그대로 +out.choicesAfterDrop = group.setChoices(dropped, [2027]).map((c) => [c.key, c.fresh]); +out.key = group.setKey({ year: 2027, set: 2 }); + +// 새 선 · 갈래 · 이어 긋기 +let net = plan.normalizePieces(plan.appendVertex(plan.newLine(one, [0, 80]), 1, [50, 80]), Y); +out.newLine = view(net).pieces; +const yearly = { + lines: [line("main", [[0, 0], [50, 0], [100, 0]])], + pieces: [piece("p1", "main", 0, 25, Y, 1), piece("p2", "main", 25, 100, 2028, 4)], +}; +net = plan.normalizePieces(plan.appendVertex(plan.addBranch(yearly, 0, 1), 1, [50, 40]), Y); +out.branch = view(net).pieces; +out.extend = view(plan.normalizePieces(plan.appendVertex(yearly, 0, [150, 0]), Y)).pieces; +out.split = view(plan.splitPiece(one, "main", 40)).pieces; +out.reverse = view(plan.reverseLine(yearly, 0)).pieces; +out.cutMiddle = view(plan.normalizePieces(removeSegment(yearly, { line: "main", index: 0 }), Y)).pieces; +const mid = { lines: [line("main", [[0, 0], [30, 0], [60, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 100, Y, 1)] }; +out.cutInner = view(plan.normalizePieces(removeSegment(mid, { line: "main", index: 1 }), Y)); + +// 이어 긋기 이음(attachHead) — 같은 세트 = 하나 · 다른 세트 = 둘 +const pair = (setB) => ({ + lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[50, 0], [100, 0]])], + pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 50, Y, setB)], +}); +out.attachSame = view(plan.normalizePieces(plan.attachHead(pair(1), 1), Y)); +out.attachOther = view(plan.normalizePieces(plan.attachHead(pair(2), 1), Y)); + +// [잇기] +out.joinTouch = view(group.joinEnds(pair(2), { line: "main", head: false }, { line: "line-1", head: true }, Y)); +out.joinTouchSame = view(group.joinEnds(pair(1), { line: "line-1", head: true }, { line: "main", head: false }, Y)); +const gap = { + lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[60, 0], [100, 0]])], + pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 40, Y, 2)], +}; +out.joinGapA = view(group.joinEnds(gap, { line: "main", head: false }, { line: "line-1", head: true }, Y)); +out.joinGapB = view(group.joinEnds(gap, { line: "line-1", head: true }, { line: "main", head: false }, Y)); +const heads = { + lines: [line("main", [[0, 0], [-50, 0]]), line("line-1", [[0, 0], [50, 0]])], + pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 50, 2028, 1)], +}; +out.joinHeads = view(group.joinEnds(heads, { line: "main", head: true }, { line: "line-1", head: true }, Y)); +const tails = { + lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[100, 0], [60, 0]])], + pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 40, Y, 2)], +}; +out.joinTails = view(group.joinEnds(tails, { line: "main", head: false }, { line: "line-1", head: false }, Y)); +out.joinSame = group.joinEnds(pair(1), { line: "main", head: true }, { line: "main", head: false }, Y); +const withBranch = plan.normalizePieces(plan.appendVertex(plan.addBranch(yearly, 0, 1), 1, [50, 40]), Y); +out.joinBranchHead = group.joinEnds(withBranch, { line: "branch-1", head: true }, { line: "main", head: false }, Y); +out.joinBranchTail = view(group.joinEnds( + { ...withBranch, lines: [...withBranch.lines, line("line-2", [[50, 40], [50, 90]])], + pieces: [...withBranch.pieces, piece("p9", "line-2", 0, 50, 2029, 1)] }, + { line: "branch-1", head: false }, { line: "line-2", head: true }, Y)); +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("group") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), *map(str, TS_FILES), "--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") + result = tmp / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result), str(CASES)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def _span(pieces: list[dict]) -> list[tuple]: + return [(p["id"], p["line"], p["from_m"], p["to_m"], p["year"], p["set"]) for p in pieces] + + +def test_거울_사례_서버와_같은_조각(r): + cases = json.loads(CASES.read_text(encoding="utf-8"))["cases"] + assert len(cases) == len(r["mirror"]) >= 6 + for case, got in zip(cases, r["mirror"]): + assert _span(got) == _span(case["expect"]), case["name"] + + +def test_세트_지정_끝에서_나눔_되돌리면_하나(r): + assert _span(r["assign"]["pieces"]) == [ + ("p1", "main", 0, 30, 2027, 1), + ("p2", "main", 30, 60, 2027, 2), + ("p3", "main", 60, 100, 2027, 1), + ] + # 거꾸로 고른 구간도 같음 · 셋이 한 세트 = 앞 조각 id 하나(마크 걷음) + assert _span(r["assignBack"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] + + +def test_경계_1m_안은_그_경계(r): + assert _span(r["assignNear"]["pieces"]) == [ + ("p1", "main", 0, 30, 2028, 1), + ("p2", "main", 30, 60, 2027, 2), + ("p3", "main", 60, 100, 2027, 1), + ] + + +def test_두_선_구간_지정_다른_선_마크는_그대로(r): + assert _span(r["assignTwo"]["pieces"]) == [ + ("p1", "main", 0, 50, 2027, 1), + ("p3", "main", 50, 100, 2028, 1), + ("p2", "line-1", 0, 40, 2028, 1), + ("p4", "line-1", 40, 100, 2027, 2), + ] + other = [p for p in r["assignKeepsOtherMark"]["pieces"] if p["line"] == "line-1"] + assert [(p["from_m"], p["to_m"]) for p in other] == [(0, 70), (70, 100)] + + +def test_고를_세트_새_번호(r): + assert r["key"] == "2027-2" + assert r["choices"] == [ + ["2027-1", False], + ["2027-3", False], + ["2027-4", True], + ["2028-1", False], + ["2028-2", True], + ["2029-1", True], + ] + # 2027-3 이 사라져도 2027-1 그대로 · 새 번호 = 남은 최댓값 + 1 + assert r["choicesAfterDrop"] == [ + ["2027-1", False], + ["2027-2", True], + ["2028-1", False], + ["2028-2", True], + ] # 넘긴 해 밖이라도 있는 세트의 해는 목록에 + + +def test_새_선_사업연도_새_번호_갈래_붙은_자리_이어_긋기(r): + assert [(p["line"], p["year"], p["set"]) for p in r["newLine"]] == [ + ("main", 2027, 1), + ("line-1", 2027, 2), + ] + branch = [p for p in r["branch"] if p["line"] == "branch-1"] + assert [(p["year"], p["set"]) for p in branch] == [(2028, 4)] # 붙은 자리(50 m) = p2 + assert _span(r["extend"]) == [("p1", "main", 0, 25, 2027, 1), ("p2", "main", 25, 150, 2028, 4)] + + +def test_기존_동작_세트가_따라감(r): + assert [(p["id"], p["set"]) for p in r["split"]] == [("p1", 1), ("p2", 1)] + assert _span(r["reverse"]) == [("p1", "main", 75, 100, 2027, 1), ("p2", "main", 0, 75, 2028, 4)] + assert _span(r["cutMiddle"]) == [("p2", "main", 0, 50, 2028, 4)] + inner = r["cutInner"] + assert [line["id"] for line in inner["lines"]] == ["main", "line-1"] + assert [(p["line"], p["year"], p["set"]) for p in inner["pieces"]] == [ + ("main", 2027, 1), + ("line-1", 2027, 1), + ] # 갈라져도 같은 세트 + + +def test_이어_긋기_이음은_같은_세트만_합침(r): + assert _span(r["attachSame"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] + assert _span(r["attachOther"]["pieces"]) == [ + ("p1", "main", 0, 50, 2027, 1), + ("p2", "main", 50, 100, 2027, 2), + ] + + +def test_잇기_닿음(r): + one = r["joinTouch"] + assert [line["id"] for line in one["lines"]] == ["main"] + assert one["lines"][0]["vertices"] == [[0, 0], [50, 0], [100, 0]] + assert _span(one["pieces"]) == [ + ("p1", "main", 0, 50, 2027, 1), + ("p2", "main", 50, 100, 2027, 2), + ] + assert _span(r["joinTouchSame"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] + + +def test_잇기_떨어짐_덧_토막_앞_선_세트(r): + a = r["joinGapA"] # 앞 = main 꼬리 → 덧 토막 50~60 = 2027-1 + assert a["lines"][0]["vertices"] == [[0, 0], [50, 0], [60, 0], [100, 0]] + assert _span(a["pieces"]) == [("p1", "main", 0, 60, 2027, 1), ("p2", "main", 60, 100, 2027, 2)] + b = r["joinGapB"] # 앞 = line-1 머리 → 덧 토막 = 2027-2 + assert len(b["lines"]) == 1 and b["lines"][0]["vertices"][0] == [0, 0] + assert [(p["from_m"], p["to_m"], p["set"]) for p in b["pieces"]] == [(0, 50, 1), (50, 100, 2)] + + +def test_잇기_머리끼리_꼬리끼리(r): + heads = r["joinHeads"] + assert len(heads["lines"]) == 1 and len(heads["lines"][0]["vertices"]) == 3 + assert [(p["year"], p["set"], p["to_m"] - p["from_m"]) for p in heads["pieces"]] == [ + (2027, 1, 50), + (2028, 1, 50), + ] + tails = r["joinTails"] + assert tails["lines"][0]["vertices"] == [[0, 0], [50, 0], [60, 0], [100, 0]] + assert [(p["from_m"], p["to_m"], p["set"]) for p in tails["pieces"]] == [ + (0, 60, 1), + (60, 100, 2), + ] + + +def test_잇기_못_함_까닭_글_갈래_꼬리는_됨(r): + assert "같은 선" in r["joinSame"] + assert "갈래 첫 점" in r["joinBranchHead"] + tail = r["joinBranchTail"] + ids = [line["id"] for line in tail["lines"]] + assert "line-2" not in ids and "branch-1" in ids + branch = next(line for line in tail["lines"] if line["id"] == "branch-1") + assert branch["branch"] == {"line": "main", "vertex": 1} + assert branch["vertices"] == [[50, 0], [50, 40], [50, 90]] + assert [(p["year"], p["set"]) for p in tail["pieces"] if p["line"] == "branch-1"] == [ + (2028, 4), + (2029, 1), + ] From 85dade9a21624d63637ee6b8be649be712494ab4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:32:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(637-2):=20B05=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=8B=A4=20=EB=82=98=EC=A4=91=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=EA=B5=AC=20=E2=80=94=20POST=20=C2=B7=20GET=20/route/l?= =?UTF-8?q?idar(=EB=BF=8C=EB=A6=AC=20B04=5FPreProcess/models=20=EC=A7=93?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9E=85=EB=A0=A5=20lidar=5Flas=20=C2=B7?= =?UTF-8?q?=20=EA=B0=88=EC=9D=8C=20=C2=B7=20=EB=84=A3=EA=B8=B0/=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=EB=A7=8C=20keep=20=C2=B7=20=EC=88=98?= =?UTF-8?q?=EC=B9=98=EC=A7=80=ED=98=95=EB=8F=84=20B=20=3D=20las=5Fselectio?= =?UTF-8?q?n=20=EC=A0=81=EA=B8=B0=20=C2=B7=20=EB=85=B8=EC=84=A0=202=20m=20?= =?UTF-8?q?=EB=8D=AE=EC=9E=84=20covered=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88)=20=C2=B7=20=EC=A7=80=ED=98=95=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20force(=EA=B0=99=EC=9D=80=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=85=88)=20=C2=B7=20main=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=8B=9C=ED=97=98(=EC=9A=94=EA=B5=AC=2054?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_Router_Lidar.py | 225 ++++++++++++++++++ .../B05_Profile_Router_TerrainSwitch.py | 7 +- .../common_util_surface_confirmation.py | 36 +++ main.py | 2 + resources/tester/test_637_lidar.py | 104 ++++++++ 5 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_Lidar.py diff --git a/B05_Profile/B05_Profile_Router_Lidar.py b/B05_Profile/B05_Profile_Router_Lidar.py new file mode 100644 index 000000000..e24accbed --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Lidar.py @@ -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), + } diff --git a/B05_Profile/B05_Profile_Router_TerrainSwitch.py b/B05_Profile/B05_Profile_Router_TerrainSwitch.py index b0f9dac7c..ac821e0b0 100644 --- a/B05_Profile/B05_Profile_Router_TerrainSwitch.py +++ b/B05_Profile/B05_Profile_Router_TerrainSwitch.py @@ -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( diff --git a/common_util/common_util_surface_confirmation.py b/common_util/common_util_surface_confirmation.py index 863df3311..e48d1649b 100644 --- a/common_util/common_util_surface_confirmation.py +++ b/common_util/common_util_surface_confirmation.py @@ -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), + ) diff --git a/main.py b/main.py index 6fee9a373..332a19372 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/tester/test_637_lidar.py b/resources/tester/test_637_lidar.py index 7679caafa..002c716e7 100644 --- a/resources/tester/test_637_lidar.py +++ b/resources/tester/test_637_lidar.py @@ -155,3 +155,107 @@ def test_DB_넣기_고치기만_갈음은_id_확정_살림(monkeypatch) -> None: 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) # 노선 그대로