diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b3745a035..2f14d7bab 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -103,6 +103,8 @@ export const STATE_REGISTRY = { "cross-columns": { bucket: "pref", scope: "global" }, /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, + /** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */ + "input-prefs": { bucket: "pref", scope: "global" }, /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ "b05-3d-projection": { bucket: "pref", scope: "global" }, "section-panel-height": { diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 9590e576c..ea40f76d9 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -14,6 +14,8 @@ * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { scale: number; @@ -213,8 +215,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const rect = svg.getBoundingClientRect(); const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx; const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx; - // 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`). - zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy); + // ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐. + zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy); }, { passive: false }, ); diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 605935355..7ffb67950 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,6 +55,35 @@ font-size: var(--btn-bar-font-size); } +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ +.b05-subpanel__popout { + flex: none; + padding: var(--btn-bar-padding); + font-size: var(--btn-bar-font-size); +} + +.b05-profile__masshaul-overlay > .b05-subpanel__popout { + position: absolute; + top: 4px; + left: var(--spacing-8); + z-index: 4; +} + +/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ +.b05-profile__masshaul-overlay.is-popout, +.b05-drainage.is-popout { + position: relative; + width: 100%; + max-width: none; + height: 100dvh; +} + +.b05-profile__masshaul-overlay.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-workflow-overlay__handle { + display: none; +} + /* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ .b05-route__panel .b06-adjust-dock, diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index ce69d30d5..304bdb07f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -1,6 +1,7 @@ /* ============================================================================= * A00_Common/design/design_profile_popout.ts * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). + * 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름. * * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 @@ -13,28 +14,39 @@ import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; -/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ -export function attachProfilePopout( - profile: DesignProfile, - slot: HTMLElement, - button: HTMLButtonElement, -): void { - const { root } = profile; +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window; + +/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */ +interface PopoutSpec { + piece: HTMLElement; + button: HTMLButtonElement; + name: string; + features: string; + docTitle: string; + /** 조각을 제자리로 돌림. */ + home: () => void; + /** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */ + onMove?: (popped: boolean) => void; +} + +/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */ +function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { + const { piece, button } = spec; let popup: Window | null = null; button.type = "button"; - button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.textContent = "창 분할"; - button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); const restore = (): void => { - if (!root.classList.contains("is-popout")) return; - root.classList.remove("is-popout"); + if (!piece.classList.contains("is-popout")) return; + piece.classList.remove("is-popout"); button.textContent = "창 분할"; - slot.append(root); + spec.home(); popup = null; - requestAnimationFrame(profile.redraw); + spec.onMove?.(false); + viewOfNode(piece).requestAnimationFrame(profile.redraw); }; const closePopup = (): void => { popup?.close(); @@ -49,28 +61,31 @@ export function attachProfilePopout( }; function open(): void { - const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720"); + const opener = viewOfNode(button); + const next = opener.open("", spec.name, spec.features); if (!next) { showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); return; } popup = next; + const source = opener.document; const doc = next.document; - doc.title = "종단 — Aislo"; - // 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. + doc.title = spec.docTitle; + // 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. doc.head.replaceChildren( - ...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) => + ...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) => doc.importNode(node, true), ), ); - Array.from(document.documentElement.attributes).forEach((attr) => + Array.from(source.documentElement.attributes).forEach((attr) => doc.documentElement.setAttribute(attr.name, attr.value), ); - doc.body.className = document.body.className; - doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; - root.classList.add("is-popout"); + doc.body.className = source.body.className; + doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`; + piece.classList.add("is-popout"); button.textContent = "창 결합"; - doc.body.replaceChildren(root); + doc.body.replaceChildren(piece); + spec.onMove?.(true); // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). FORWARD_TYPES.forEach((type) => @@ -93,7 +108,75 @@ export function attachProfilePopout( new (next as Window & typeof globalThis).ResizeObserver(() => { next.cancelAnimationFrame(pending); pending = next.requestAnimationFrame(profile.redraw); - }).observe(root); + }).observe(piece); next.addEventListener("pagehide", restore); } } + +/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ +export function attachProfilePopout( + profile: DesignProfile, + slot: HTMLElement, + button: HTMLButtonElement, +): void { + button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; + button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; + attachPopout(profile, { + piece: profile.root, + button, + name: "aislo-profile-popout", + features: "popup,width=1400,height=720", + docTitle: "종단 — Aislo", + home: () => slot.append(profile.root), + }); +} + +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ +export function attachSubPanelPopouts(profile: DesignProfile): void { + const subButton = (title: string): HTMLButtonElement => { + const button = document.createElement("button"); + button.className = "ui-btn ui-btn--ghost b05-subpanel__popout"; + button.title = title; + return button; + }; + + const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); + const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); + if (massOverlay && massHandle) { + const button = subButton("유토곡선 창 분할"); + massOverlay.append(button); + attachPopout(profile, { + piece: massOverlay, + button, + name: "aislo-masshaul-popout", + features: "popup,width=1400,height=420", + docTitle: "유토곡선 — Aislo", + // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). + home: () => massHandle.before(massOverlay), + onMove: (popped) => { + // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). + massHandle.hidden = popped; + massOverlay.style.height = ""; + }, + }); + } + + const drainage = profile.side; + const header = drainage?.root.querySelector(".b05-drainage__header"); + if (drainage && header) { + const button = subButton("배수유역 창 분할"); + header.prepend(button); + const content = drainage.root.parentElement; + attachPopout(profile, { + piece: drainage.root, + button, + name: "aislo-drainage-popout", + features: "popup,width=1000,height=720", + docTitle: "배수유역 — Aislo", + // 제자리 = 종단 content 끝(종단 본문 오른쪽). + home: () => content?.append(drainage.root), + // 지도 크기 관찰을 놓인 창 것으로(325-1). + onMove: () => drainage.rehome(), + }); + } +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 9296eb899..3374528c1 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -24,7 +24,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; -import { attachProfilePopout } from "./design_profile_popout"; +import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -179,6 +179,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { }); shared.profileSlot.append(profile.root); attachProfilePopout(profile, shared.profileSlot, popoutButton); + attachSubPanelPopouts(profile); shared.profile = profile; return profile; } diff --git a/A00_Common/input_prefs.ts b/A00_Common/input_prefs.ts new file mode 100644 index 000000000..49fff86db --- /dev/null +++ b/A00_Common/input_prefs.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * input_prefs.ts + * 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀. + * + * - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름) + * - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import) + * - 휠 확대 방향 하나로 모든 화면 일체 — 당김 = 휠을 몸 쪽으로(deltaY > 0) 굴리면 확대 + * - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0) + * ========================================================================== */ + +export type WheelZoom = "pull" | "push"; + +export interface InputPrefs { + /** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */ + wheelZoom: WheelZoom; + /** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */ + cadAliases: Record; +} + +/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */ +export const INPUT_PREFS_KEY = "aislo:pref:input-prefs"; + +export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "pull", cadAliases: {} }; + +const cleanAliases = (raw: unknown): Record => { + const out: Record = {}; + if (!raw || typeof raw !== "object") return out; + for (const [id, list] of Object.entries(raw as Record)) { + if (!Array.isArray(list)) continue; + const aliases = list + .filter((a): a is string => typeof a === "string") + .map((a) => a.toUpperCase()); + if (aliases.length) out[id.toUpperCase()] = aliases; + } + return out; +}; + +/** 지금 설정 — 부를 때마다 저장소에서 읽음 */ +export function inputPrefs(): InputPrefs { + try { + const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY); + if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + const value = JSON.parse(raw) as Partial | null; + return { + wheelZoom: value?.wheelZoom === "push" ? "push" : "pull", + cadAliases: cleanAliases(value?.cadAliases), + }; + } catch { + return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + } +} + +/** 휠 한 번이 확대인가 — 설정 방향을 따름 */ +export const wheelIn = (deltaY: number): boolean => + inputPrefs().wheelZoom === "push" ? deltaY < 0 : deltaY > 0; + +/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄 + * ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */ +export const isTrackpadPinch = (e: Pick): boolean => + e.ctrlKey && Math.abs(e.deltaY) < 50; + +/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */ +export const wheelEventIn = (e: Pick, pinch = false): boolean => + pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY); diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 27ac5da6a..6d7fefd78 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -28,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links"; import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; -import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; +import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection"; import { createStatusBar } from "./spreadsheet_statusbar"; import { mountTabs } from "./spreadsheet_tabs"; import { mountToolbar } from "./spreadsheet_toolbar"; @@ -90,6 +90,9 @@ export interface SpreadsheetHandle { /** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) · * `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */ pasteTable(html: string, check?: PasteCheck): Promise; + /** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) · + * 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */ + select(범위: string | string[], 시트?: string): boolean; destroy(): void; } @@ -350,6 +353,18 @@ export function createSpreadsheet( return 이름; }, pasteTable: (html, check) => pasteTableAt(ctx, html, check), + select(range, sheetKey) { + const target = sheetKey + ? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey) + : ctx.sheet(); + const next = target && selectRanges(target, typeof range === "string" ? [range] : range); + if (!target || !next) return false; + showSheet(target.id); + if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘 + select(next, true); + grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index da403b2d4..9e0b97db7 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -13,6 +13,7 @@ import "./spreadsheet_grid.css"; import { colIndex, toA1 } from "./spreadsheet_address"; +import { wheelZoomsIn } from "../touch_bridge"; import { buildColGeometry, buildOutlineRuns, @@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { if (!ev.ctrlKey) return; ev.preventDefault(); const before = zoom; - const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); + const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332) + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step))); if (next === before) return; zoom = next; colGeo = scaledGeometry(baseColGeo, zoom); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 46c0c3e8c..750318511 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; } +/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */ +export function selectRanges(sheet: Sheet, texts: string[]): Selection | null { + const list = merges(sheet); + const 범위: CellRange[] = []; + for (const t of texts) { + const g = parseRange(t); + if (!g) return null; + 범위.push(expand(g, list)); + } + if (!범위.length) return null; + const at = { r: 범위[0].r0, c: 범위[0].c0 }; + return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } }; +} + /** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { const r0 = Math.min(a, b); diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts index 6c83aa700..6fc8a810d 100644 --- a/A00_Common/touch_bridge.ts +++ b/A00_Common/touch_bridge.ts @@ -11,6 +11,8 @@ * 마우스 · 펜 이벤트는 건드리지 않음. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + const LONG_MS = 500; const SLOP_PX = 10; const DOUBLE_MS = 300; @@ -31,9 +33,8 @@ let contactPx = 0; /** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */ export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px); -/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */ -export const wheelZoomsIn = (e: WheelEvent): boolean => - isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0; +/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */ +export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e)); const ownsTouch = (t: EventTarget | null): boolean => t instanceof Element && !!t.closest("[data-touch-own]"); diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 703c27d4e..90791b126 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -350,6 +350,18 @@ async def run_auto_design_chain( logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") + # 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도 + # 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다. + try: + from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task + from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults + + _schedule_background_task( + precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}" + ) + except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다 + logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id) + return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index d5adca1d9..8bf42cc44 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; +import { wheelEventIn } from "../A00_Common/input_prefs"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; @@ -246,11 +247,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. - * 휠을 위로 올리면 멀어진다(사용자 지시). */ + * 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */ function onWheel(event: WheelEvent): void { if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); - zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP); + zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP); } /** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */ diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 73543179e..e46176d19 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -211,6 +211,8 @@ export interface StationTickOptions { /** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고, * 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */ onLabel?: (box: StationLabelBox) => void; + /** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */ + chainages?: ReadonlyArray; } /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ @@ -253,7 +255,10 @@ export function drawStationTicks( // 이미 그린 라벨과 겹치는 자리는 비켜 놓는다. const drawn: Array<{ x: number; y: number; half: number }> = []; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += interval) { + const marks: number[] = []; + if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage); + for (const chainage of marks) { const stationNo = Math.round(chainage / interval); if (stationNo % step !== 0) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 3285d710d..97ac66fb1 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -100,6 +100,8 @@ export interface RoutePlanResponse { violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; + /** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */ + station_snap?: boolean; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ @@ -220,6 +222,8 @@ export interface CrossPreviewRequest { station_interval_m: number; /** 횡단 반폭(m) — 없으면 서버 기본값. */ cross_half_width_m?: number; + /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ + station_snap?: boolean; } /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ba7278c84..13a1e0fd5 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -3,8 +3,9 @@ 예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중. -계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 -받은 노드로 기존 `POST /route/replan`([확인])을 부른다. +계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로 +`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로 +기존 `POST /route/replan`([확인])을 부른다. 갈래(`mode`): · follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선. @@ -182,6 +183,20 @@ def write_initial_choice( return choice +def resolve_inputs( + max_grade: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, +) -> tuple[float, float, float]: + """(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이.""" + stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) + grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) + # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) + goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + return stream_offset, grade_smooth, goal_grade + + def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" if mode != "weighted": @@ -240,10 +255,9 @@ def generate_initial_route( if len(expected) < 2: raise ValueError("예상노선 정점이 2개 미만입니다.") used = resolve_weights(mode, weights) - stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) - grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) - goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + stream_offset, grade_smooth, goal_grade = resolve_inputs( + max_grade, stream_offset_m, grade_smooth_m, target_grade + ) def measure( outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index f09392ef6..1f7e2d3df 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_LENGTH_M, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, NoRoute, RouteGraph, ) +from common_util.common_util_route_geometry import densify_route from common_util.common_util_route_polyline import build_planned_polyline ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} @@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05 SIMILAR_M = 10.0 # 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 CONSTRAINT_SHARE = 1.0 +# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼 +TRACE_DENSIFY_M = 2.0 Measure = Callable[..., dict[str, Any]] Route = tuple[Any, dict[str, Any]] @@ -103,14 +107,27 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를 + # 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙) + # 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: - outline = build_planned_polyline( + floor = build_planned_polyline( nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) ) - traced[key] = (outline, measure(outline, **kw)) + least = (floor, measure(floor, **kw)) + path = densify_route(nodes, TRACE_DENSIFY_M) + fitted = build_planned_polyline(path, min_radius_m=min_radius_m) + larger = (widen(fitted, path, min_radius_m), fitted) + traced[key] = next( + ( + route + for route in ((outline, measure(outline, **kw)) for outline in larger) + if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"] + ), + least, + ) return traced[key] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 13ec86a8e..de8e9fc8f 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -1,8 +1,9 @@ """노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A). 후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다. -편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을 -바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. +편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을 +바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙 +(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. 계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다. diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py new file mode 100644 index 000000000..f425eaa70 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -0,0 +1,110 @@ +"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」). + +`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침. + +1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을 +[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M` +인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로. + +고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로 +펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다. +""" + +from __future__ import annotations + +import math + +import numpy as np +from shapely.geometry import LineString, Point + +from common_util.common_util_route_polyline import ( + PlannedPolyline, + _inner_angle_deg, + build_planned_polyline, +) +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +Xy = tuple[float, float] + + +def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]: + """곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴.""" + starts = {curve.node_first: curve for curve in outline.curves} + inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)} + points: list[Xy] = [] + flags: list[bool] = [] + radii: list[float | None] = [] + for index, node in enumerate(outline.nodes): + if index in inside: + continue + curve = starts.get(index) + points.append(curve.apex if curve else (node.x, node.y)) + flags.append(curve is not None) + radii.append(round(curve.radius_m, 2) if curve else None) + return points, flags, radii + + +def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray: + return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0] + + +def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray: + """R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m).""" + a = np.asarray(apex, dtype=float) + p = samples - a # (S, 2) 교각점 기준 + units = [(np.asarray(end, dtype=float) - a) for end in (before, after)] + halves = [float(np.hypot(*u)) / 2 for u in units] + units = [u / (2 * h) for u, h in zip(units, halves)] + inner = math.radians(_inner_angle_deg(before, apex, after)) + tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1) + legs = [] + for unit, half in zip(units, halves): + along = p @ unit + side = np.abs(_cross(unit, p)) + legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half)))) + bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1])) + center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2) + starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점 + q = p[None] - center # (M, S, 2) 중심 → 점 + turn = _cross(starts[0], starts[1]) + within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0) + arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf) + return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1) + + +def widen( + outline: PlannedPolyline, + path: list[Xy], + min_radius_m: float, + max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M, +) -> PlannedPolyline: + """곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.""" + if not outline.curves: + return outline + points, flags, radii = _flat(outline) + line = LineString(path) + coords = np.asarray(path, dtype=float) + along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + for index in range(1, len(points) - 1): + if not flags[index]: + continue + before, apex, after = points[index - 1], points[index], points[index + 1] + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + if half_tan <= 1e-9: + continue + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan + candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터 + # 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼 + ends = [ + line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2)) + for end in (before, after) + ] + samples = coords[(along >= min(ends)) & (along <= max(ends))] + if not candidates.size or not len(samples): + continue + ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m] + if ok.size: + radii[index] = float(ok[0]) + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py new file mode 100644 index 000000000..5f4cd944b --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -0,0 +1,128 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). + +같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 +셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). + +층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 +`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 +[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다. + +키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 +기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때 +다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임). +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncIterator + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + DEFAULT_WEIGHTS, + Terrain, + _pool_key, + resolve_inputs, + resolve_weights, +) +from common_util.common_util_json import atomic_write_json + +ALGO_VERSION = 1 +STORE_DIR = "B05_Profile/route_recommend" +# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 +DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") + +# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음) +_LOCKS: dict[str, asyncio.Lock] = {} + + +def store_dir(project_root: Path) -> Path: + return Path(project_root) / STORE_DIR + + +def request_fingerprint( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + mode: str, + weights: dict[str, float] | None, + max_grade: float, + min_radius_m: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, + with_profile: bool, + design_speed: int, +) -> tuple[str, str]: + """(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.""" + pool = _pool_key( + expected, + terrain, + streams, + max_grade, + min_radius_m, + *resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade), + ).hex() + used = resolve_weights(mode, weights) + tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION) + key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest() + return key, pool + + +def is_default( + mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any +) -> bool: + """기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음.""" + empty = not any(value is not None for value in (criteria or {}).values()) + plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS + return empty and plain and terrain is None + + +def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: + """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" + try: + data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: + return None + return data + + +def write_stored( + project_root: Path, + key: str, + pool: str, + request: dict[str, Any], + result: dict[str, Any], + default: bool, +) -> bool: + """새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음).""" + path = store_dir(project_root) / f"{key}.json" + if path.exists(): + return False + atomic_write_json( + path, + { + "version": ALGO_VERSION, + "key": key, + "pool": pool, + "default": default, + "created_at": datetime.now().isoformat(timespec="seconds"), + "request": request, + "result": result, + }, + ) + return True + + +@asynccontextmanager +async def pool_lock(pool: str) -> AsyncIterator[None]: + # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 + async with _LOCKS.setdefault(pool, asyncio.Lock()): + yield diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index b9e354689..5dc47a85a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]: 곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고, 받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다. """ + curves = _read_design_curve_table(project_root).get("curves") + return ( + [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] + ) + + +def load_station_snap(project_root: Path) -> bool: + """[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔.""" + return _read_design_curve_table(project_root).get("station_snap") is True + + +def _read_design_curve_table(project_root: Path) -> dict[str, Any]: + """설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict.""" route_path = planned_route_working_path(project_root) if not route_path.is_file(): route_path = planned_route_initial_path(project_root) target = route_path.with_name(f"{route_path.stem}_curves.json") if not target.is_file(): - return [] + return {} try: data = json.loads(target.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target) - return [] - curves = data.get("curves") if isinstance(data, dict) else None - return ( - [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] - ) + return {} + return data if isinstance(data, dict) else {} # ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ──────────── @@ -435,6 +445,7 @@ def run_section_generation( # 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라 # 종전의 측점별 실측 반경으로 물러선다. design_curves=_load_design_curves(project_root), + station_snap=load_station_snap(project_root), ) stage_root = project_root / _STAGE_SUBDIR @@ -548,6 +559,8 @@ def sample_structure_sections( crs=crs, # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. design_curves=_load_design_curves(project_root), + # 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남. + station_snap=load_station_snap(project_root), ) def structural(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 2505e8535..22e567aaa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -6,6 +6,9 @@ ⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` (`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`. +⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔ +`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험 +`resources/tester/test_224_2_station_snap.py`. """ import math @@ -275,6 +278,35 @@ def _design_curve_spans( return sorted(spans) +#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m). +CURVE_STATION_EDGE_M = 0.005 + + +def snap_curve_stations( + stations: np.ndarray | list[float], + spans: list[tuple[float, ...]], +) -> np.ndarray: + """측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC + (floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로. + + `spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리). + """ + values = [float(value) for value in stations] + for span in spans: + start, end = float(span[0]), float(span[1]) + middle = (start + end) / 2.0 + inside = [ + index + for index, value in enumerate(values) + if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M + ] + if not inside: + continue + nearest = min(inside, key=lambda index: abs(values[index] - middle)) + values[nearest] = float(math.floor(middle + 0.5)) + return np.unique(np.asarray(values, dtype=np.float64)) + + def _design_curve_widenings( station_chainage: np.ndarray, spans: list[tuple[float, float, float, str]], @@ -319,8 +351,12 @@ def generate_sections( source_snapshot: dict[str, Any] | None = None, crs: str | None = None, design_curves: list[dict[str, Any]] | None = None, + station_snap: bool = False, ) -> dict[str, Any]: - """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" + """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. + + `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2). + """ options = options or SectionGenerationOptions() options.validate() points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) @@ -331,6 +367,9 @@ def generate_sections( long_z, long_valid = sampler.sample_xy(long_xy) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) + spans = _design_curve_spans(points, route_chainage, design_curves or []) + if station_snap: + station_chainage = snap_curve_stations(station_chainage, spans) # 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다. regular_rounded = {round(float(value), 6) for value in station_chainage} # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 @@ -372,7 +411,6 @@ def generate_sections( # 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로 # 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다). # 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다. - spans = _design_curve_spans(points, route_chainage, design_curves or []) if spans: plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings( station_chainage, spans diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..744eb5a2c 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -26,7 +26,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) + POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) """ import asyncio @@ -41,10 +41,17 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import AliasChoices, BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice +from B05_Profile.B05_Profile_Engine_Sections import load_station_snap +from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 + RouteInitialCriteria, + RouteInitialRequest, + RouteInitialWeights, + RouteReplanRequest, + RouteVertexInput, +) from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( densify_route, @@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) _PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} -class RouteVertexInput(BaseModel): - """계획노선 꺾임점 하나 — 사업지 좌표계(m). - - 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — - · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. - · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. - · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. - """ - - x: float - y: float - curve: bool = True - """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" - radius_m: float | None = None - """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" - - -class RouteInitialWeights(BaseModel): - """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" - - expected: float = Field(50.0, ge=0, le=100) - earthwork: float = Field(25.0, ge=0, le=100) - grade: float = Field(25.0, ge=0, le=100) - - -class RouteInitialCriteria(BaseModel): - """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. - - 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 - 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. - """ - - max_grade_pct: float | None = Field(None, ge=0, le=100) - min_radius_m: float | None = Field(None, ge=0) - stream_offset_m: float | None = Field(None, ge=0) - grade_smooth_m: float | None = Field(None, ge=0) - # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) - target_grade_pct: float | None = Field(None, ge=0, le=100) - - -class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · - safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" - - mode: Literal[ - "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" - ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) - weights: RouteInitialWeights | None = None - criteria: RouteInitialCriteria | None = None - # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. - with_profile: bool = True - # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). - terrain: Literal["las", "sheet"] | None = None - - -class RouteReplanRequest(BaseModel): - """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" - - vertices: list[RouteVertexInput] = Field(default_factory=list) - initial: RouteInitialRequest | None = None - """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" - balanced_profile: bool = False - """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 - 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" - terrain: Literal["las", "sheet"] | None = None - """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" - - async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: """(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" pool = get_db_pool() @@ -305,9 +244,12 @@ def _write_planned_polyline( simplify: bool = True, curve_flags: list[bool] | None = None, radii: list[float | None] | None = None, + station_snap: bool | None = None, ) -> dict: """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔). + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. @@ -321,13 +263,13 @@ def _write_planned_polyline( ) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) - _curves_path(path).write_text( - json.dumps( - {"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]}, - ensure_ascii=False, - ), - encoding="utf-8", - ) + table: dict[str, Any] = { + "min_radius_m": round(radius_m, 3), + "curves": [c.as_dict() for c in result.curves], + } + if station_snap is not None: + table["station_snap"] = bool(station_snap) + _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") return { "nodes": len(result.nodes), "curves": result.curve_count, @@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), + "station_snap": await asyncio.to_thread(load_station_snap, project_root), "current_initial": await asyncio.to_thread(read_initial_choice, project_root), "initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m), } @@ -604,11 +547,17 @@ async def replan_route( radii = [vertex.radius_m for vertex in request.vertices] # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). + # 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음. working_path = planned_route_working_path(project_root) - previous = working_path.read_bytes() if working_path.is_file() else None + keep = (working_path, _curves_path(working_path), _nodes_path(working_path)) + previous = {path: path.read_bytes() if path.is_file() else None for path in keep} summary = await asyncio.to_thread( functools.partial( - _write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii + _write_planned_polyline, + simplify=False, + curve_flags=curve_flags, + radii=radii, + station_snap=request.station_snap, ), working_path, nodes, @@ -633,10 +582,11 @@ async def replan_route( ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. - if previous is None: - working_path.unlink(missing_ok=True) - else: - working_path.write_bytes(previous) + for path, saved in previous.items(): + if saved is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(saved) logger.warning( "계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s", project_id, diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 88e90e6b1..5bb6f494e 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -30,14 +30,35 @@ async def initial_route( ) -> dict[str, Any] | JSONResponse: """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). - **파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). + 작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로 + `POST /route/replan`([확인]). + """ + return await compute_initial(project_id, request) + + +async def compute_initial( + project_id: UUID, request: replan.RouteInitialRequest +) -> dict[str, Any] | JSONResponse: + """같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로. + + 응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import ( generate_initial_route, load_terrain_and_streams, ) - from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes + from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + cover_shortfall, + project_pipes, + ) + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + is_default, + pool_lock, + read_stored, + request_fingerprint, + write_stored, + ) from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, @@ -81,37 +102,62 @@ async def initial_route( async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, request.terrain) + inputs = { + "mode": request.mode, + "weights": weights, + "max_grade": max_grade, + "min_radius_m": radius_m, + "stream_offset_m": stream_offset_m, + "grade_smooth_m": grade_smooth_m, + "target_grade": None if target_pct is None else target_pct / 100.0, + "design_speed": design_speed or 20, + "with_profile": request.with_profile, + } started = time.perf_counter() try: terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = ( - await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None - ) - result = await asyncio.to_thread( - functools.partial( - generate_initial_route, - [(x, y) for x, y in expected], - terrain, - streams, - mode=request.mode, - weights=weights, - max_grade=max_grade, - min_radius_m=radius_m, - stream_offset_m=stream_offset_m, - grade_smooth_m=grade_smooth_m, - target_grade=None if target_pct is None else target_pct / 100.0, - design_speed=design_speed or 20, - pipes=pipes, - with_profile=request.with_profile, - ) + route = [(x, y) for x, y in expected] + key, pool_key = await asyncio.to_thread( + functools.partial(request_fingerprint, route, terrain, streams, **inputs) ) + async with pool_lock(pool_key): + stored = await asyncio.to_thread(read_stored, project_root, key) + if stored is not None: + result = stored["result"] + else: + # 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다. + result = await asyncio.to_thread( + functools.partial( + generate_initial_route, route, terrain, streams, pipes=None, **inputs + ) + ) + await asyncio.to_thread( + write_stored, + project_root, + key, + pool_key, + request.model_dump(), + result, + is_default( + request.mode, + weights, + criteria.model_dump() if criteria else None, + request.terrain, + ), + ) + profile = result["metrics"].get("profile") + if request.with_profile and profile and "error" not in profile: + pipes = await asyncio.to_thread(project_pipes, project_root) + if pipes: + profile.update(cover_shortfall(profile, result["planned"], pipes)) except (FileNotFoundError, ValueError) as error: return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) logger.info( - "초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", + "초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", request.mode, + "저장본" if stored is not None else "셈", project_id, time.perf_counter() - started, result["metrics"]["length_m"], @@ -122,10 +168,28 @@ async def initial_route( "status": "success", "project_id": str(project_id), **result, + "stored": stored is not None, "current_initial": current, } +async def precompute_defaults(project_id: UUID) -> None: + """업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만.""" + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + + started = time.perf_counter() + for mode in DEFAULT_MODES: + # 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`) + request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow") + outcome = await compute_initial(project_id, request) + if isinstance(outcome, JSONResponse): + logger.warning( + "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id + ) + return + logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) + + def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" import json diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 559d64724..197578e4b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel): #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. cross_half_width_m: float | None = Field(None, gt=0) terrain: AnalysisTerrain | None = None + #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. + station_snap: bool = False def _cross_preview( @@ -177,7 +179,14 @@ def _cross_preview( else defaults.station_interval_m, cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, ) - result = generate_sections(built.vertices, sampler, options) + # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. + result = generate_sections( + built.vertices, + sampler, + options, + design_curves=[curve.as_dict() for curve in built.curves], + station_snap=request.station_snap, + ) sections = result["cross_sections"] if not sections: return None diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0578c7334..5221c9d7d 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from config.config_system import ( @@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel): route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None + + +# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ───────────────────────────── + + +class RouteVertexInput(BaseModel): + """계획노선 꺾임점 하나 — 사업지 좌표계(m). + + 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — + · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. + · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. + · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. + """ + + x: float + y: float + curve: bool = True + """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" + radius_m: float | None = None + """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" + + +class RouteInitialWeights(BaseModel): + """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" + + expected: float = Field(50.0, ge=0, le=100) + earthwork: float = Field(25.0, ge=0, le=100) + grade: float = Field(25.0, ge=0, le=100) + + +class RouteInitialCriteria(BaseModel): + """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. + + 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 + 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. + """ + + max_grade_pct: float | None = Field(None, ge=0, le=100) + min_radius_m: float | None = Field(None, ge=0) + stream_offset_m: float | None = Field(None, ge=0) + grade_smooth_m: float | None = Field(None, ge=0) + # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) + target_grade_pct: float | None = Field(None, ge=0, le=100) + + +class RouteInitialRequest(BaseModel): + """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · + safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" + + mode: Literal[ + "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" + ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) + weights: RouteInitialWeights | None = None + criteria: RouteInitialCriteria | None = None + # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. + with_profile: bool = True + # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). + terrain: Literal["las", "sheet"] | None = None + + +class RouteReplanRequest(BaseModel): + """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" + + vertices: list[RouteVertexInput] = Field(default_factory=list) + initial: RouteInitialRequest | None = None + """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" + balanced_profile: bool = False + """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 + 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" + terrain: Literal["las", "sheet"] | None = None + """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" + station_snap: bool = False + """[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음.""" diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index d8d32dcd5..75a2d3ab5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -536,8 +536,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin: (index) => selectBasin(index), }); - // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 - // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). + // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다(늘릴 때마다 확대되면 보던 자리를 잃음). const resizeObserver = observeViewportSize(viewport, { meta: () => meta, anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), @@ -696,5 +695,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra resizeObserver.disconnect(); widthResizer.dispose(); }, + rehome: () => resizeObserver.rehome(), }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 7ed1fe8b7..365d8a1ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,6 +52,8 @@ export interface DrainagePanel { /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ setIntervalSpans: (spans: ReadonlyArray) => void; + /** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */ + rehome: () => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 868e450c5..f6e4539ba 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -23,6 +23,7 @@ import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { viewOf } from "../common_util/common_util_view_window"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -454,7 +455,8 @@ export function preserveViewOnResize( }; } -/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ +/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. `rehome` = 판이 다른 창으로 옮겨 간 뒤 그 창의 + * 관찰자로 바꿔 낌(직전 크기를 지켜 배율 유지 · 325-1) · `disconnect` = 정리. */ export function observeViewportSize( viewport: HTMLElement, ports: { @@ -463,9 +465,9 @@ export function observeViewportSize( apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; redraw: () => void; }, -): ResizeObserver { +): { rehome: () => void; disconnect: () => void } { let last: { width: number; height: number } | null = null; - const observer = new ResizeObserver(() => { + const onResize = (): void => { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); @@ -482,9 +484,22 @@ export function observeViewportSize( } last = { width, height }; ports.redraw(); - }); - observer.observe(viewport); - return observer; + }; + // 새 창 크기 변화는 그 창의 관찰자만 안다 — 판이 놓인 창의 것으로 단다. + const watch = (): ResizeObserver => { + const view = viewOf(viewport) as Window & typeof globalThis; + const next = new view.ResizeObserver(onResize); + next.observe(viewport); + return next; + }; + let observer = watch(); + return { + rehome: () => { + observer.disconnect(); + observer = watch(); + }, + disconnect: () => observer.disconnect(), + }; } /** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index b5c22166a..4bb99cbf0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -133,6 +133,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, @@ -143,7 +145,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; + const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, @@ -169,7 +171,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { let mainDragRef: { chart: number; mass: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; + const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { chart: Math.max( @@ -191,7 +193,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 3771fdf1e..fe6035427 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -67,7 +67,8 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - const massOpen = !deps.massHaul.overlay.hidden; + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 0600b48e1..d219230b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -438,7 +438,12 @@ export function createRouteMassHaulPanel( window: undefined, hideStations: true, } - : { ...params.axis, window: windowState }; + : { + ...params.axis, + // 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2). + viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange, + window: windowState, + }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a69bcf658..65d2759b3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -59,6 +59,7 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { curveSpans, snapCurveStations, stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame"; import { lockColumn, readRouteLocks, @@ -109,7 +110,7 @@ export async function openRouteEditModal( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; - const { minorContourButton, terrainSelect } = chrome; + const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -138,6 +139,24 @@ export async function openRouteEditModal( let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */ + let stationSnap = false; + let savedStationSnap = false; + /** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로. + * 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */ + const stations = (): number[] | null => { + if (!stationSnap) return null; + const line = plannedLine.length ? plannedLine : planned; + return snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curveInfo), + ); + }; + const setStationSnap = (on: boolean): void => { + stationSnap = on; + stationSnapButton.classList.toggle("is-active", on); + stationSnapButton.setAttribute("aria-pressed", String(on)); + }; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; @@ -165,6 +184,7 @@ export async function openRouteEditModal( })), min_radius_m: minRadiusM || 12, station_interval_m: stationIntervalM, + station_snap: stationSnap, }), onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); @@ -217,6 +237,11 @@ export async function openRouteEditModal( const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); + stationSnapButton.addEventListener("click", () => { + setStationSnap(!stationSnap); + void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. + draw(); + }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ let viewReady = false; const viewMemory = createViewMemory(projectId, () => @@ -307,6 +332,7 @@ export async function openRouteEditModal( toScreen, uprightRad: rotation.uprightRad(), dragging: pointer.dragging(), + chainages: stations(), }), measure: measure.marks(), crossWidth: crossPreview.watched(), @@ -319,6 +345,7 @@ export async function openRouteEditModal( locked: apexLock, picked, stationIntervalM, + stations: stations(), }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); @@ -448,6 +475,7 @@ export async function openRouteEditModal( canvas, status, stationIntervalM, + stations, state: () => ({ planned, plannedLine, @@ -530,8 +558,12 @@ export async function openRouteEditModal( projectId, nodes: () => ({ planned, curveOn, curveRadius }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). - changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain, + changed: () => + (history?.isDirty() ?? false) || + pickedTerrain() !== designTerrain || + stationSnap !== savedStationSnap, terrain: pickedTerrain, + stationSnap: () => stationSnap, initial, profileShown: strip.settle, close, @@ -554,6 +586,8 @@ export async function openRouteEditModal( minRadiusM = plan.min_radius_m ?? 0; limitRadiusM = plan.limit_radius_m ?? 0; limitArcM = plan.limit_curve_length_m ?? 0; + savedStationSnap = plan.station_snap === true; + setStationSnap(savedStationSnap); // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index ed8036b2a..05f05f7d2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -27,6 +27,8 @@ export interface RouteApplyParams { changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; + /** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */ + stationSnap: () => boolean; /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; @@ -97,6 +99,7 @@ export function bindRouteApply(params: RouteApplyParams): void { // 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. balanced_profile: await params.profileShown(), terrain: params.terrain(), + station_snap: params.stationSnap(), }, ), ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index fb0c4208d..c6e66feee 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -36,6 +36,8 @@ export interface RouteEditChrome { measureText: HTMLElement; measureButton: HTMLButtonElement; minorContourButton: HTMLButtonElement; + /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ + stationSnapButton: HTMLButtonElement; /** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */ terrainSelect: HTMLSelectElement; } @@ -65,6 +67,9 @@ export function createRouteEditChrome(): RouteEditChrome { title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선 + @@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome { '[data-act="contour-minor"]', )!; const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; + const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!; return { overlay, @@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome { measureText, measureButton, minorContourButton, + stationSnapButton, terrainSelect, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 1c6a3648c..c0b491141 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -35,6 +35,7 @@ export interface CrossPreviewParams { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; station_interval_m: number; + station_snap?: boolean; }; /** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */ onShown?: () => void; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts index f7e1f4b56..67bc9aeb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -5,6 +5,10 @@ * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at` * (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고 * 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림. + * + * ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` + * ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` · + * `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`. * ========================================================================== */ type Vertex = [number, number]; @@ -75,3 +79,98 @@ export function stationFrameAt( const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length]; return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] }; } + +/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */ +export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0; +/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */ +export const CURVE_STATION_EDGE_M = 0.005; + +/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */ +export interface CurveEnds { + start?: ReadonlyArray | null; + apex?: ReadonlyArray | null; + end?: ReadonlyArray | null; + radius_m?: number | null; +} + +/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ +export function stationsAlong( + line: ReadonlyArray, + intervalM: number, + includeEndpoint: boolean, +): number[] { + const { chainage } = cleanPolyline(line); + const total = chainage.length ? chainage[chainage.length - 1] : 0; + const values: number[] = []; + if (intervalM > 0) + for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM); + if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0); + if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total); + return values; +} + +/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */ +function projectChainage( + points: Vertex[], + chainage: number[], + xy: ReadonlyArray, +): [number, number] { + let bestGap = Infinity; + let bestAt = 0; + for (let index = 0; index < points.length - 1; index += 1) { + const [sx, sy] = points[index]; + const vx = points[index + 1][0] - sx; + const vy = points[index + 1][1] - sy; + const length2 = vx * vx + vy * vy; + const safe = length2 > 1e-12 ? length2 : 1; + const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe)); + const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]); + if (gap < bestGap) { + bestGap = gap; + bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio; + } + } + return [bestGap, bestAt]; +} + +/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림. + * `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */ +export function curveSpans( + line: ReadonlyArray, + curves: ReadonlyArray, +): Array<[number, number]> { + const { points, chainage } = cleanPolyline(line); + if (points.length < 2) return []; + const spans: Array<[number, number]> = []; + for (const { start, apex, end, radius_m } of curves) { + if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue; + const [startGap, startAt] = projectChainage(points, chainage, start); + const [endGap, endAt] = projectChainage(points, chainage, end); + if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue; + const cross = + (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]); + if (Math.abs(cross) < 1e-12) continue; + spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]); + } + return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 · + * 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */ +export function snapCurveStations( + stations: ReadonlyArray, + spans: ReadonlyArray, +): number[] { + const values = [...stations]; + for (const [start, end] of spans) { + const middle = (start + end) / 2; + let nearest = -1; + values.forEach((value, index) => { + if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return; + if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle)) + nearest = index; + }); + if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5); + } + return [...new Set(values)].sort((a, b) => a - b); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 6e63790ff..7199fa9b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -189,6 +189,8 @@ export interface InitialChoice { similar?: SimilarRoute[] | null; /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ bandWidthM?: number | null; + /** 서버 추천 저장본에서 꺼낸 결과인가(계획서 327) — 서버 `stored`. */ + stored?: boolean; /** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */ balance?: BalanceProfile | null; } @@ -336,6 +338,7 @@ export function normalizeRouteInitial( terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, similar: parseSimilar(body.similar), bandWidthM: num(body.band_width_m), + stored: body.stored === true, balance: parseBalance(m.profile), criteria: body.criteria ? { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index fbef8b083..4eef74ec3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -321,13 +321,15 @@ export function stationAtScreen( py: number, maxPx: number, labels: ReadonlyArray = [], + /** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */ + chainages: ReadonlyArray | null = null, ): number | null { // 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이 // 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다. const shown = labels.length ? new Set(labels.map((box) => Math.round(box.chainage / intervalM))) : null; - const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown); + const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages); // 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다. return tick ?? stationLabelAtScreen(labels, px, py); } @@ -373,6 +375,7 @@ function stationTickAtScreen( py: number, maxPx: number, shown: ReadonlySet | null, + chainages: ReadonlyArray | null, ): number | null { if (line.length < 2 || !(intervalM > 0)) return null; const cumulative: number[] = [0]; @@ -386,7 +389,10 @@ function stationTickAtScreen( let best: number | null = null; let bestDistance = maxPx; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += intervalM) { + const marks: number[] = []; + if (chainages) marks.push(...chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += intervalM) marks.push(chainage); + for (const chainage of marks) { if (shown && !shown.has(Math.round(chainage / intervalM))) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = line[cursor - 1]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 3763161ea..2da4c4f10 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -64,6 +64,8 @@ export interface RouteEditPointerParams { canvas: HTMLCanvasElement; status: HTMLElement; stationIntervalM: number; + /** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: () => ReadonlyArray | null; state: () => RouteEditPointerState; setPicked: (index: number) => void; setPickedContour: (index: number) => void; @@ -194,6 +196,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP py, pickPx(STATION_HIT_PX), stationLabelBoxes(canvas), + params.stations(), ); if (chainage === null) return false; void params.crossPreview.open(chainage); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 6d8a4f854..d42d94aef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -462,7 +462,9 @@ export function openRecommendModal(params: RecommendParams): void { preview = choice; if (choice.balance) seen.set(label, choice.balance); else seen.delete(label); // 종단 없는 갈래(예상노선 추적) - stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`; + stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${ + choice.stored ? " (저장된 결과)" : "" + } — [적용]을 눌러야 편집 창에 놓입니다.`; } catch (error) { if (closed || mine !== seq) return; stateEl.textContent = "셈하지 못했습니다."; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index c3c713495..d088f80b7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -29,7 +29,7 @@ const LOGIC_HELP: Record = { follow: { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ - "새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음", + "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", "전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴", "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", ], diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 3a35c064e..90c6664ec 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -94,6 +94,8 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; + /** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: ReadonlyArray | null; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray; /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ @@ -478,6 +480,7 @@ function drawStationMarks( toScreen: (x, y) => scene.toScreen([x, y]), uprightRad: scene.uprightRad, onLabel: (box) => labels.push(box), + chainages: scene.stations ?? undefined, }, ); const total = polylineLengthM(line); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts index e35789f81..aa372ee5b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -42,6 +42,8 @@ export interface SlopeDrawArgs { uprightRad: number; /** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */ dragging: boolean; + /** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + chainages?: ReadonlyArray | null; } export interface SlopeLabels { @@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { /** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */ let cachedLine: ReadonlyArray | null = null; let cachedInterval = 0; + /** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */ + let cachedKey = ""; let chainages: number[] = []; let heights: Array = []; /** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */ @@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { let asking: ReadonlyArray | null = null; let timer: ReturnType | null = null; - function schedule(line: ReadonlyArray, intervalM: number): void { + function schedule( + line: ReadonlyArray, + intervalM: number, + marks: ReadonlyArray | null, + ): void { if (asking === line) return; if (timer) clearTimeout(timer); asking = line; timer = setTimeout(() => { timer = null; - const wanted = stationChainages(lengthOf(line), intervalM); + const total = lengthOf(line); + const wanted = marks + ? [...marks.filter((value) => value < total - 1e-6), total] + : stationChainages(total, intervalM); const points = pointsAt(line, wanted); void fetchRouteElevations(params.projectId, points) .then((z) => { if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다. cachedLine = line; cachedInterval = intervalM; + cachedKey = marks?.join() ?? ""; chainages = wanted; heights = z; stationPoints = points; @@ -160,9 +172,11 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { const { line, intervalM } = args; if (line.length < 2 || !(intervalM > 0)) return 0; if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다. - const fresh = cachedLine === line && cachedInterval === intervalM; + const marks = args.chainages ?? null; + const fresh = + cachedLine === line && cachedInterval === intervalM && cachedKey === (marks?.join() ?? ""); if (!fresh) { - if (!args.dragging) schedule(line, intervalM); + if (!args.dragging) schedule(line, intervalM, marks); return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다. } // 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 — diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 6833d8a20..53ff81b73 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -89,7 +89,10 @@ if (typeof document !== "undefined") /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { - return !!lastPointer?.closest(".b05-route-profile"); + // 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2). + return !!lastPointer?.closest( + ".b05-route-profile, .b05-profile__masshaul-overlay, .b05-drainage", + ); } /** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김. diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 76d4b028c..0623a007e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -7,6 +7,7 @@ * 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`. * ========================================================================== */ +import { wheelZoomsIn } from "../../A00_Common/touch_bridge"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { @@ -454,7 +455,7 @@ export function renderDiagram( "wheel", (ev) => { ev.preventDefault(); - setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); + setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332) }, { passive: false }, ); diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py new file mode 100644 index 000000000..e4fcf4458 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -0,0 +1,231 @@ +"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · +우리 돈 로직과 견준 근거 후보. + +읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 +다음 머리 앞까지 한 블록 · +출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · +그 밖 = 건설품셈 · +직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). +견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · +건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · +같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. +M01 · 원본 로직은 읽기만. +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs + +SHEET = "단가산출" +COMMON = "보통인부" +BONUS_PARTS = ("공통", "토목") +_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") +_BOOK = re.compile(r"품[셈줌]") +_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") +_TYPOS = (("깍기", "깎기"),) +_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +# ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +@lru_cache(maxsize=2) +def _trade_names(whole_id: int) -> dict[str, str]: + """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" + out: dict[str, str] = {} + for row in m01.loaded()[1].numbers.get("LB", {}).values(): + name = str(row.get("이름") or "") + out[_flat(name)] = name + for old in row.get("옛이름") or []: + out.setdefault(_flat(old), name) + return out + + +def trade_names() -> dict[str, str]: + return _trade_names(id(m01.loaded()[1])) + + +def sources(text: str) -> list[list[str]]: + """글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」.""" + out = [] + for hit in _BOOK.finditer(text): + num = labor_inputs._NUMBER.search(text, hit.end()) + if not num or num.start() - hit.end() > 20: + continue + before = text[max(0, hit.start() - 12) : hit.start()] + book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈" + if [book, num.group()] not in out: + out.append([book, num.group()]) + return out + + +def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: + """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}).""" + for at, row in enumerate(rows[:10]): + flat = [_flat(v) for v in row] + if "산출근거" in flat: + money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat} + return at + 1, flat.index("산출근거"), money + return 0, 1, {} + + +def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: + """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}` + (행 = 엑셀 1부터).""" + if not rows: + return {} + start, col, money = _columns(rows) + names = trade_names() + blocks: dict[int, dict[str, Any]] = {} + block: dict[str, Any] | None = None + for at, row in enumerate(rows[start:], start + 1): + text = row[col] if col < len(row) else None + if not isinstance(text, str) or not text.strip(): + continue + head = None if text[:1].isspace() else _HEAD.match(text) + if head: + block = blocks.setdefault( + int(head.group(1)), + { + "번": int(head.group(1)), + "이름": head.group(2), + "단위": head.group(3).strip(), + "행": [at, at], + "출처": [], + "직종": [], + "금액": { + key: v + for key, c in money.items() + for v in [row[c] if c < len(row) else None] + if isinstance(v, (int, float)) and not isinstance(v, bool) + }, + }, + ) + continue + if block is None: + continue + block["행"][1] = at + block["출처"] += [one for one in sources(text) if one not in block["출처"]] + trade = _TRADE.match(text) + name = names.get(_flat(trade.group(1))) if trade else None + if name and name not in block["직종"]: + block["직종"].append(name) + return blocks + + +# ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── +@lru_cache(maxsize=2) +def _ours(whole_id: int) -> dict[str, dict[str, Any]]: + """{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 · + 직종 = 호표 인력(LB) 요소.""" + files, whole = m01.loaded() + lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()} + out = {} + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + kind = str(row.get("구분") or "") + book, _, part = kind.partition(" ") + trades = { + lb[str(h.get("요소"))] + for h in row.get("호표") or [] + if isinstance(h, dict) and str(h.get("요소")) in lb + } + clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or ""))) + out[str(row.get("키"))] = { + "책": book, + "부문": part, + "구분": kind, + "조항": clauses, + "직종": trades, + } + return out + + +def ours() -> dict[str, dict[str, Any]]: + return _ours(id(m01.loaded()[1])) + + +def _head(number: str) -> str: + return number.rsplit("-", 1)[0] + + +def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: + """(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만.""" + nums = [n for book, n in theirs if book == our["책"]] + same = sorted(n for n in nums if n in our["조항"]) + if same: + return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" + heads = {_head(n) for n in nums} + near = sorted(n for n in our["조항"] if _head(n) in heads) + if near: + return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음" + return 0.0, [], "" + + +def _text(line: dict[str, Any]) -> str: + text = f"{line.get('이름') or ''} {line.get('규격') or ''}" + for old, new in _TYPOS: + text = text.replace(old, new) + return text + + +def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: + """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례.""" + basis = line.get("근거") or {} + theirs = [list(one) for one in basis.get("출처") or []] + if not theirs: + return [] + trades = set(basis.get("직종") or []) - {COMMON} + text = _text(line) + mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 + grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 + table = ours() + scored = [] + for one in got["로직"]: + our = table.get(one["로직"]) + if not our: + continue + score, clauses, tag = _clause(theirs, our) + if not score: + continue + shared = sorted(trades & our["직종"]) + if not shared and not any(a in b or b in a for a in mine for b in one["words"]): + continue + tags = [tag] + if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS: + score += 0.1 + tags.append(f"건설 {our['부문']}") + if shared: + score += 0.5 + tags.append("직종 " + " · ".join(shared)) + unit = hopyo_match.fits(line.get("단위"), one["단위"]) + if unit: + score += 0.15 + tags.append("단위 맞음") + dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) + why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} + scored.append( + (score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}}) + ) + scored.sort(key=lambda x: (-x[0], -x[1])) + return [one for _, _, one in scored] + + +def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: + """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례.""" + if not basis: + return named + by_name = {c.get("로직"): c["점수"] for c in named} + out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis] + out += [c for c in named if c.get("로직") in by_name] + return sorted(out, key=lambda c: -c["점수"]) diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index 38d11a81e..9995de2b4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -11,6 +11,9 @@ 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). +행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · +근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · +`단산` = 블록마다 `{번, 이름, 단위, 행}`. """ from __future__ import annotations @@ -20,6 +23,8 @@ import re from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis + SHEET = "일위대가" SUMMARY = "일위대가총괄표" COSTS = ("노무비", "재료비", "경비") @@ -67,8 +72,8 @@ def _out(value: Decimal | None) -> int | float | None: # ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: - """시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음.""" - wanted = (SHEET, SUMMARY) + """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음.""" + wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") try: @@ -212,7 +217,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: start, cols = _columns(rows, ("합계", *COSTS)) hopyos: list[dict[str, Any]] = [] raw: list[list[dict[str, Any]]] = [] - for row in rows[start:]: + for at, row in enumerate(rows[start:], start + 1): head = _flat(_cell(row, cols.get("호표"))) name_ = _text(_cell(row, cols.get("품명"))) hit = _HOPYO.fullmatch(head) @@ -224,6 +229,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "규격": _text(_cell(row, cols.get("규격"))), "단위": _text(_cell(row, cols.get("단위"))), "금액": _money(row, cols, 1), + "행": [at, at], } ) raw.append([]) @@ -244,9 +250,15 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, } line["갈래"] = kind_of(line) + line["행"] = hopyos[-1]["행"][1] = at raw[-1].append(line) if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} wrong: list[str] = [] totals = _summary(sheets.get(SUMMARY)) for hopyo, lines in zip(hopyos, raw, strict=True): @@ -258,7 +270,13 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: {**_json(hopyo), "줄": [_json(line) for line in lines]} for hopyo, lines in zip(hopyos, raw, strict=True) ] - return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}} + basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()] + return { + "시트": SHEET, + "호표": out, + "단산": basis, + "검산": {"어긋남": wrong, "총괄표": len(totals)}, + } def _json(value: Any) -> Any: diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py new file mode 100644 index 000000000..39af897fa --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -0,0 +1,211 @@ +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · +읽기 전용 보기). + +xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · +행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · +숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. +""" + +from __future__ import annotations + +import io +from typing import Any + +from openpyxl.utils import get_column_letter + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel + +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +HEAD_ROWS = 4 +_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} +_VALIGN = {0: "top", 1: "center"} +_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"} +_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right")) + + +class _Styles: + """서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`.""" + + def __init__(self) -> None: + self.table: list[dict[str, Any]] = [{}] + self._index: dict[str, int] = {"{}": 0} + + def add(self, style: dict[str, Any]) -> int: + key = repr(sorted(style.items())) + if not style: + return 0 + if key not in self._index: + self._index[key] = len(self.table) + self.table.append(style) + return self._index[key] + + +def _rgb(book: Any, index: int) -> str | None: + got = book.colour_map.get(index) + return "#{:02X}{:02X}{:02X}".format(*got) if got else None + + +def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]: + font = book.font_list[xf.font_index] + out: dict[str, Any] = {} + if font.name != base.name: + out["글꼴"] = font.name + if font.height != base.height: + out["크기"] = font.height / 20 + for key, on in ( + ("굵게", font.bold), + ("기울임", font.italic), + ("밑줄", font.underline_type), + ("취소선", font.struck_out), + ): + if on: + out[key] = True + color = _rgb(book, font.colour_index) + if color and color != "#000000": + out["글자색"] = color + if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)): + out["채움"] = fill + align = xf.alignment + if align.hor_align in _HALIGN: + out["가로"] = _HALIGN[align.hor_align] + if align.vert_align in _VALIGN: + out["세로"] = _VALIGN[align.vert_align] + if align.text_wrapped: + out["줄바꿈"] = True + sides = {} + for name, side in _SIDES: + line = getattr(xf.border, f"{side}_line_style") + if line: + one = {"선": _LINES.get(line, "dashed")} + color = _rgb(book, getattr(xf.border, f"{side}_colour_index")) + if color and color != "#000000": + one["색"] = color + sides[name] = one + if sides: + out["테두리"] = sides + fmt = book.format_map.get(xf.format_key) + if fmt and fmt.format_str and fmt.format_str != "General": + out["형식"] = fmt.format_str + return out + + +def _value(cell: Any) -> Any: + import xlrd + + if cell.ctype == xlrd.XL_CELL_TEXT: + return cell.value or None + if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE): + return int(cell.value) if cell.value == int(cell.value) else cell.value + if cell.ctype == xlrd.XL_CELL_BOOLEAN: + return bool(cell.value) + if cell.ctype == xlrd.XL_CELL_ERROR: + return xlrd.error_text_from_code.get(cell.value, "#N/A") + return None + + +def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]: + base = book.font_list[0] + by_xf: dict[int, int] = {} + cells: dict[str, dict[str, Any]] = {} + for r in range(sh.nrows): + for c in range(sh.ncols): + cell = sh.cell(r, c) + if cell.xf_index not in by_xf: + by_xf[cell.xf_index] = styles.add( + _xf_style(book, book.xf_list[cell.xf_index], base) + ) + one: dict[str, Any] = {} + if (value := _value(cell)) is not None: + one["값"] = value + if by_xf[cell.xf_index]: + one["서식"] = by_xf[cell.xf_index] + if one: + cells[f"{get_column_letter(c + 1)}{r + 1}"] = one + merged = [ + f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}" + for r0, r1, c0, c1 in sh.merged_cells + ] + cols = { + get_column_letter(c + 1): { + "폭": round(info.width / 256, 2), + **({"숨김": True} if info.hidden else {}), + } + for c, info in sh.colinfo_map.items() + if c < sh.ncols + } + rows = { + str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})} + for r, info in sh.rowinfo_map.items() + if r < sh.nrows and (info.height != sh.default_row_height or info.hidden) + } + frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos) + freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS} + out = { + "id": sid, + "이름": sh.name.strip(), + "칸": cells, + "병합": merged, + "열": cols, + "행": rows, + "틀고정": {k: v for k, v in freeze.items() if v}, + } + if sh.default_row_height: + out["기본"] = {"행높이": sh.default_row_height / 20} + return out + + +def _xls(data: bytes) -> dict[str, Any]: + import xlrd + + book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO()) + styles = _Styles() + found = {sh.name.strip(): sh for sh in book.sheets()} + sheets = [ + _xls_sheet(book, found[name], styles, f"s{at}") + for at, name in enumerate((n for n in SHEETS if n in found), 1) + ] + font = book.font_list[0] + return { + "기본": {"글꼴": font.name, "크기": font.height / 20}, + "서식": styles.table, + "시트": sheets, + } + + +def _xlsx(name: str, data: bytes) -> dict[str, Any]: + # ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 + found = hopyo_excel._sheets(name, data) + sheets = [] + for at, title in enumerate((n for n in SHEETS if n in found), 1): + rows = found[title] + width = max( + (c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")), + default=0, + ) + cells = { + f"{get_column_letter(c + 1)}{r + 1}": { + "값": v if isinstance(v, (int, float, bool)) else str(v) + } + for r, row in enumerate(rows) + for c, v in enumerate(row[:width]) + if v not in (None, "") + } + sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) + return {"서식": [{}], "시트": sheets} + + +def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]: + """엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과 + (행 범위).""" + xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx") + book = _xlsx(name, data) if xlsx else _xls(data) + keep = ("번", "이름", "행") + return { + "책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"}, + "호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []], + "단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []], + } diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index dc894c8a4..c9ccbc557 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -4,6 +4,8 @@ 씨앗(일위대가조합.json 메모 · 읽기만) ③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · 뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ++ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 · +로직 키로 합쳐 점수 더함) · ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). @@ -27,6 +29,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs @@ -295,9 +298,10 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5 + line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 ) -> list[dict[str, Any]]: - """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" + """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` + (None = 모두).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -402,11 +406,9 @@ def match_line( return out top = [{**_public(x), "점수": 9} for x in found] same = [_public(x) for x in found] - rest = [ - c - for c in candidates(line, pool, got) - if {k: v for k, v in c.items() if k != "점수"} not in same - ] + basis = hopyo_basis.candidates(line, got) if pool == "로직" else [] + named = candidates(line, pool, got, None if basis else 5) + rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same] out["후보"] = (top + rest)[:5] return {**out, "상태": "후보" if out["후보"] else "없음"} @@ -461,7 +463,8 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: if no is None: got = targets() hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] - brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] + keep = ("번", "이름", "규격", "단위", "금액", "맞춤") + brief = [{k: h.get(k) for k in keep} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index c632a409d..b82ad7b51 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -1,5 +1,5 @@ """M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` · -맞춤 기억 `실무자료/맞춤.json`. +맞춤 기억 `실무자료/맞춤.json` · 엑셀꼴 격자 `실무자료/격자/<키>.json`(329-1 · 파일과 같이 지움). 파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` (층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. @@ -20,6 +20,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store DIRNAME = "실무자료" MEMORY = "맞춤" +GRID = "격자" +NO_GRID = "격자 없음 — 다시 올리면 보임" _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -42,6 +44,10 @@ def _load(path: Path) -> Any: return None +def _grid_path(folder: str | Path, key: str) -> Path: + return _dir(folder) / GRID / _path(folder, key).name + + def brief(doc: dict[str, Any]) -> dict[str, Any]: """목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`.""" keep = ("키", "파일", "올린때", "올린이", "시트") @@ -69,8 +75,14 @@ def read(folder: str | Path, key: str) -> dict[str, Any]: return doc -def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]: - """읽은 결과 → 새 키로 씀 → 목록 한 줄.""" +def save( + folder: str | Path, + name: str, + parsed: dict[str, Any], + who: Any, + grid: dict[str, Any] | None = None, +) -> dict[str, Any]: + """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄.""" with _LOCK: have = [ int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) @@ -83,16 +95,28 @@ def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dic "올린이": who, **parsed, } + if grid is not None: + book = {**grid["책"], "열": key} + atomic_write_json(_grid_path(folder, key), {**grid, "책": book}) atomic_write_json(_path(folder, key), doc) return brief(doc) +def grid(folder: str | Path, key: str) -> dict[str, Any]: + """엑셀꼴 격자 `{책, 호표, 단산}` — 없으면(옛 파일) 404.""" + doc = _load(_grid_path(folder, key)) + if not isinstance(doc, dict): + raise store.StoreError(404, NO_GRID) + return doc + + def delete(folder: str | Path, key: str) -> None: path = _path(folder, key) with _LOCK: if not path.is_file(): raise store.StoreError(404, f"없는 실무자료 「{key}」") path.unlink() + _grid_path(folder, key).unlink(missing_ok=True) # ── 맞춤 기억 ───────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 1f028a4b1..55739aa3a 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -176,7 +177,11 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: raise store.StoreError(400, str(error)) from error - return hopyo_store.save(store.root(), name, parsed, who) + try: + grid = hopyo_grid.read(name, data, parsed) + except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) + grid = None + return hopyo_store.save(store.root(), name, parsed, who, grid) @router.get("/layers/{layer}/hopyo") @@ -205,7 +210,7 @@ def hopyo_upload( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB · + """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · 엉터리 · 시트 없음 400.""" folder = _root(session, layer, owner, write=True) data = file.file.read(HOPYO_MAX_BYTES + 1) @@ -225,6 +230,16 @@ def hopyo_read( return _call(folder, hopyo_match.view, folder, key) +@router.get("/layers/{layer}/hopyo-grid/{key}") +def hopyo_grid_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · + 옛 파일 404.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_store.grid, folder, key) + + @router.get("/layers/{layer}/hopyo/{key}/{no}") def hopyo_one( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 7a00414cc..8e771bad7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -149,3 +149,102 @@ gap: var(--spacing-4); margin-bottom: var(--spacing-8); } + +/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */ +.m02-hopyo-sheet__dialog > .ui-modal__body { + overflow: hidden; +} + +.m02-hopyo-sheet { + display: grid; + grid-template-columns: minmax(0, 1fr) 22rem; + gap: var(--spacing-8); + flex: 1; + min-height: 0; +} + +.m02-hopyo-sheet__grid { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.m02-hopyo-sheet__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.m02-hopyo-sheet__list { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-hopyo-sheet__item { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border-left: 3px solid transparent; + cursor: pointer; +} + +.m02-hopyo-sheet__item:hover { + background: var(--color-surface); +} + +.m02-hopyo-sheet__item.is-current { + border-left-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.m02-hopyo-sheet__no { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-hopyo-sheet__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-hopyo-sheet__result { + max-height: 40%; + overflow: auto; +} + +.m02-hopyo-sheet__result:empty { + display: none; +} + +/* 고르기 창 근거(330) — 후보 꼬리표 · 엑셀 근거 줄 */ +.m02-hopyo__basis { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-hopyo__basis.is-ok { + border-color: var(--color-success); + color: var(--color-success); +} + +.m02-hopyo__source { + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index e11e7dcec..12312fba2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; @@ -34,6 +35,22 @@ export interface HopyoBrief { 금액: Money; /** [들어감 수, 줄 수] */ 맞춤: [number, number]; + /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ + 행?: [number, number]; +} + +/** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */ +export interface HopyoSpan { + 번: number; + 이름: string; + 행: [number, number]; +} + +/** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */ +export interface HopyoGrid { + 책: Workbook; + 호표: HopyoSpan[]; + 단산: HopyoSpan[]; } export interface HopyoView extends HopyoFile { @@ -42,6 +59,22 @@ export interface HopyoView extends HopyoFile { 맞춤: [number, number]; } +/** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */ +export interface HopyoBasisHit { + 조항: string[]; + 직종: string[]; + 단위: boolean; + 점수: number; + 까닭: string; +} + +/** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */ +export interface HopyoLineBasis { + 출처: [string, string][]; + 직종: string[]; + 행: [number, number]; +} + /** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; @@ -51,6 +84,8 @@ export interface HopyoTarget { 규격?: string; 단위?: string | null; 점수?: number; + /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ + 근거?: HopyoBasisHit; } export interface HopyoMatch { @@ -75,6 +110,8 @@ export interface HopyoLine { 금액: Money; 비고: string; 맞춤: HopyoMatch; + /** 단가산출 근거(330 · 「단산N참조」 줄만) */ + 근거?: HopyoLineBasis; } export interface HopyoOne extends HopyoFile { @@ -136,6 +173,10 @@ export const listHopyo = async (scope: HopyoScope): Promise => export const readHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`)); +/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */ +export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo-grid/${enc(key)}`)); + export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}`)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index 04cdd4c89..c460ce6a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Pick.ts * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 - * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음. + * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · + * 고른 호표마다 참조 줄). 프로젝트 층엔 없음. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -25,7 +26,42 @@ export function withHopyo(lib: LibraryPick): LibraryPick { { value: "precombo", text: tc("Kind_Combo") }, { value: HOPYO_KIND, text: "실무 호표" }, ], - more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)), + more: async () => { + const rows = await listHopyoPicks(scope); + const files = new Map(); + for (const row of rows) { + const key = `${row.층}|${row.주인 ?? ""}|${row.키}`; + files.set(key, [...(files.get(key) ?? []), row]); + } + return [ + ...rows.map((row) => hopyoExtra(row, lib)), + ...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)), + ]; + }, + }; +} + +/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */ +export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra { + return { + kind: HOPYO_KIND, + key: `${key}|엑셀`, + tag: "실무 호표", + name: "엑셀로 보기 · 여러 호표", + sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`, + unit: `호표 ${n}`, + onPick: () => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => + m.openHopyoSheet({ + scope: { layer: row.층, owner: row.주인 }, + key: row.키, + 파일: row.파일, + canWrite: true, + target: { layer: lib.layer, owner: lib.owner }, + onImport: (got, h) => + lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null), + }), + ), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts new file mode 100644 index 000000000..e547de48e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -0,0 +1,282 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Sheet.ts + * 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 + * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기]. + * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 + * · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · + * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨 + * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; +import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import { + buildHopyo, + readHopyo, + readHopyoGrid, + type HopyoBrief, + type HopyoBuilt, + type HopyoGrid, + type HopyoScope, + type HopyoSpan, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_View"; +import "./M02_MasterTemplete_Hopyo.css"; + +export interface HopyoSheetOptions { + /** 실무자료 파일 층 */ + scope: HopyoScope; + key: string; + 파일: string; + /** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */ + canWrite: boolean; + /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ + target?: HopyoScope; + /** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */ + 호표?: number; + /** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */ + 단산행?: number | [number, number]; + /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ + onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; +} + +const ILWI = "일위대가"; +const DANSAN = "단가산출"; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */ +export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null => + list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null; + +/** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */ +export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`; + +/** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */ +export function lastColumn(sheet: Pick): string { + let best = "A"; + for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) { + const c = /^[A-Z]+/.exec(k)?.[0]; + if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c; + } + return best; +} + +/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ +export const rowsText = (행: number | [number, number]): string => + typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; + +export function openHopyoSheet(opt: HopyoSheetOptions): void { + let sheet: SpreadsheetHandle | null = null; + let closed = false; + let briefs: HopyoBrief[] = []; + let spans: HopyoSpan[] = []; + let ilwi = ""; // 일위대가 시트 id + let last = "O"; + let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름 + let current: number | null = opt.호표 ?? null; + const checked = new Set(); + const items = new Map(); + + const left = el("div", { + className: "m02-hopyo-sheet__grid", + children: [note("엑셀을 읽는 중…")], + }); + const search = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" }, + }) as HTMLInputElement; + const list = el("div", { className: "m02-hopyo-sheet__list" }); + const count = note(""); + const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" }); + go.disabled = true; + const result = el("div", { className: "m02-hopyo-sheet__result" }); + const right = el("div", { + className: "m02-hopyo-sheet__side", + children: [ + search, + el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }), + list, + result, + ], + }); + + openModal({ + title: `엑셀 보기 — ${opt.파일}`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo-sheet__dialog", + beforeClose: async () => { + closed = true; + sheet?.destroy(); + return true; + }, + mount: (body) => + body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })), + }); + + const paintGo = (): void => { + go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`; + go.disabled = !checked.size; + }; + + /** 목록 강조 · 보이게(시트 고름은 그대로) */ + function mark(no: number | null): void { + items.get(current ?? NaN)?.classList.remove("is-current"); + current = no; + const row = items.get(no ?? NaN); + row?.classList.add("is-current"); + row?.scrollIntoView({ block: "nearest" }); + } + + /** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */ + function focus(no: number): void { + mark(no); + const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행; + if (!sheet || !span) return; + const text = spanText(span, last); + if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); + } + + function paintList(): void { + const q = search.value.trim().toLowerCase(); + items.clear(); + const shown = briefs.filter( + (h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q), + ); + list.replaceChildren( + ...shown.map((h) => { + const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + box.checked = checked.has(h.번); + box.hidden = !opt.canWrite; + box.addEventListener("click", (e) => e.stopPropagation()); + box.addEventListener("change", () => { + if (box.checked) checked.add(h.번); + else checked.delete(h.번); + paintGo(); + }); + const row = el("div", { + className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`, + attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() }, + children: [ + box, + el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), + el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), + note(`${h.맞춤[0]}/${h.맞춤[1]}`), + ], + }); + row.addEventListener("click", () => focus(h.번)); + items.set(h.번, row); + return row; + }), + ); + count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`; + } + search.addEventListener("input", paintList); + + async function mountSheet(grid: HopyoGrid): Promise { + const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); + if (closed) return; + const book = structuredClone(grid.책); + const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0]; + if (!first) return void left.replaceChildren(note("시트 없음")); + ilwi = first.id; + last = lastColumn(first); + book.활성 = first.id; + left.replaceChildren(); + sheet = createSpreadsheet(left, book, { + readOnly: true, + onSelect: (s) => { + at = s; + if (s.시트 !== ilwi) return; + const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0])); + if (no !== null && no !== current) mark(no); + }, + }); + // 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈) + left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return; + const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0])); + if (no !== null) focus(no); + }); + } + + async function load(): Promise { + try { + const [file, grid] = await Promise.all([ + readHopyo(opt.scope, opt.key), + readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))), + ]); + if (closed) return; + briefs = file.호표; + spans = grid instanceof Error ? [] : grid.호표; + paintList(); + if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); + await mountSheet(grid); + if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); + else if (current !== null) focus(current); + } catch (error) { + if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`)); + } + } + + async function importPicked(): Promise { + const nos = [...checked].sort((a, b) => a - b); + const target = opt.target ?? opt.scope; + const rows: HTMLElement[] = []; + let ok = 0; + go.disabled = true; + for (const no of nos) { + const h = briefs.find((x) => x.번 === no); + if (!h || closed) continue; + const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; + const got = await buildHopyo(target, from); + ok += 1; + opt.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + head, + el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }), + el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), + el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), + ], + }), + ); + } catch (error) { + rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); + } + result.replaceChildren( + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) => + el("th", { text: t }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ); + } + paintGo(); + showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning"); + } + go.addEventListener("click", () => void importPicked()); + + void load(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 74ce15703..3bd583efb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Side.ts * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 - * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만. + * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · + * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; @@ -66,6 +67,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); await refresh(); await show(got); + const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet"); + sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite }); }) .catch((error) => showToast(why(error), "error")) .finally(() => (upload.disabled = !opt.canWrite)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 5601885cf..63a5eef75 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -2,7 +2,9 @@ * M02_MasterTemplete_Hopyo_View.ts * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · - * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. + * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · + * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) · + * 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330). * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ @@ -16,15 +18,18 @@ import { readHopyo, readHopyoOne, saveHopyoMemory, + type HopyoBasisHit, type HopyoBuilt, type HopyoGuess, type HopyoLine, + type HopyoLineBasis, type HopyoMemory, type HopyoOne, type HopyoScope, type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; +import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; @@ -52,6 +57,9 @@ const why = (error: unknown): string => (error instanceof Error ? error.message const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); +/** 엑셀 보기 창(329) — 열 때 불러옴 */ +const openSheet = (o: HopyoSheetOptions): void => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => el("div", { @@ -109,6 +117,42 @@ export function memoryOf( return null; } +/** 근거 맞음 — 조항 · 직종 · 단위 모두 맞음(330 [근거 맞음 모두 확정]) */ +export const basisOk = (b: HopyoBasisHit | undefined): boolean => + !!b && b.조항.length > 0 && b.직종.length > 0 && b.단위; + +/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ +export const basisTag = (b: HopyoBasisHit | undefined): string => + b + ? [ + b.조항.length ? `조항 ${b.조항.join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + b.단위 ? "단위 같음" : "", + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ +export const sourceText = (b: HopyoLineBasis | undefined): string => + b + ? [ + b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */ +export function basisPick(line: HopyoLine): HopyoTarget | null { + const m = line.맞춤; + if (m.상태 !== "후보") return null; + if (m.대상 && basisOk(m.대상.근거)) return m.대상; + return (m.후보 ?? []).find((c) => basisOk(c.근거)) ?? null; +} + export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 @@ -176,6 +220,12 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + createButton({ + label: "엑셀로 보기", + size: "sm", + onClick: () => + openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }), + }), ...(opt.canWrite ? [dropButton(file)] : []), ], }), @@ -225,9 +275,19 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); all.disabled = !same.length; all.addEventListener("click", () => void confirmAll(no, same)); + tools.push(all); + // 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음 + if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) { + const fit = h.줄.filter((x) => basisPick(x)); + const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" }); + ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만"; + ok.disabled = !fit.length; + ok.addEventListener("click", () => void confirmBasis(no, fit)); + tools.push(ok); + } const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); make.addEventListener("click", () => void build(no, false)); - tools.push(all, make); + tools.push(make); } const missing = h.줄.filter((x) => !x.맞춤.들어감); view.replaceChildren( @@ -240,6 +300,18 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(h.단위), note(`엑셀 단가 ${won(h.금액.계)}`), note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), + createButton({ + label: "엑셀에서 보기", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일: got.파일, + canWrite: opt.canWrite, + 호표: no, + }), + }), ...tools, ], }), @@ -307,7 +379,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement variant: "ghost", size: "sm", onClick: () => - choose(line, (t) => + choose(line, got.파일, (t) => guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( (g) => (picked.set(line.차례, g), repaint()), fail, @@ -355,8 +427,8 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }); } - /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ - function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { + /** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */ + function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void { const material = MATERIAL.has(line.갈래); openModal({ title: `고르기 — ${line.이름} ${line.규격}`.trim(), @@ -367,14 +439,48 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement close(); done(t); }; - const list = (line.맞춤.후보 ?? []).map((c) => - createButton({ + const list = (line.맞춤.후보 ?? []).map((c) => { + const button = createButton({ label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, variant: "ghost", size: "sm", onClick: () => pick(c), - }), - ); + }); + const tag = basisTag(c.근거); + if (tag && c.근거) + button.append( + el("span", { + className: `m02-hopyo__basis${basisOk(c.근거) ? " is-ok" : ""}`, + text: tag, + attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, + }), + ); + return button; + }); + const basis = line.근거; + const source = basis + ? [ + el("div", { + className: "m02-hopyo__bar m02-hopyo__source", + children: [ + note(`엑셀 근거 — ${sourceText(basis)}`), + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일, + canWrite: opt.canWrite, + 단산행: basis.행, + }), + }), + ], + }), + ] + : []; const more = createButton({ label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", size: "sm", @@ -385,6 +491,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); body.append( + ...source, el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, @@ -411,6 +518,24 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement await remember(no, body); } + /** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */ + async function confirmBasis(no: number, lines: HopyoLine[]): Promise { + const body: Record = {}; + for (const x of lines) { + const c = basisPick(x); + const m = x.맞춤; + if (!c) continue; + const g = + c === m.대상 + ? { 대상: c, 입력값: m.입력값, 환산: m.환산 } + : await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null); + const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null); + if (one) body[m.열쇠] = one; + } + if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning"); + await remember(no, body); + } + async function build(no: number, fresh: boolean): Promise { try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index b966ba85c..1d653f7a2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { wheelZoomsIn } from "../A00_Common/touch_bridge"; import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import "./M02_MasterTemplete_Labor_Diagram.css"; @@ -313,7 +314,7 @@ function mountShell(host: HTMLElement): State { if (!e.ctrlKey) return; e.preventDefault(); const r = viewport.getBoundingClientRect(); - zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); + zoomAt(st, wheelZoomsIn(e) ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); // ☰ [설정] 방향(332) }, { passive: false }, ); diff --git a/config/config_system_design.py b/config/config_system_design.py index 13fb522ef..4e7c43d93 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -38,6 +38,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", # 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. # 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) +# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나 +ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0")) # 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 # 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index ac649dbf7..031fa6f4d 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -15,7 +15,9 @@ function load(file) { ? {} : p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일 ? load(path.join(ROOT, "A00_Common", "key_shortcut.ts")) - : require(p); + : p.endsWith("input_prefs") // 휠 방향(332 · Node = 기본 당김) + ? load(path.join(ROOT, "A00_Common", "input_prefs.ts")) + : require(p); new Function("exports", "module", "require", js)(box.exports, box, req); return box.exports; } @@ -100,30 +102,30 @@ r.consts = { r.listeners = win.calls.map((c) => [c.type, c.capture]); } { - // 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 + // 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 당김(deltaY > 0 · 332 기본) const { win, view } = setup(); const wheel = (deltaY) => win.fire("wheel", { deltaY }); const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]); - const offEv = wheel(-100); + const offEv = wheel(100); const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length }; win.fire("keydown", { code: "KeyZ" }); - const up = wheel(-100); + const up = wheel(100); const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) }; - wheel(100); - wheel(100); + wheel(-100); + wheel(-100); const down = zoomLog().at(-1); wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음 const flat = zoomLog().at(-1); - for (let i = 0; i < 30; i++) wheel(-50); + for (let i = 0; i < 30; i++) wheel(50); const max = zoomLog().at(-1); - for (let i = 0; i < 30; i++) wheel(120); + for (let i = 0; i < 30; i++) wheel(-120); const min = zoomLog().at(-1); win.fire("keyup", { code: "KeyZ" }); const n = zoomLog().length; - const afterOff = wheel(-100); + const afterOff = wheel(100); const afterOffZoomed = zoomLog().length !== n; win.fire("keydown", { code: "KeyZ" }); - wheel(-100); + wheel(100); r.wheel = { off: offState, up: upState, @@ -134,17 +136,17 @@ r.consts = { afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed }, reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸 pure: [ - m.nextLoupeZoom(2, -1), m.nextLoupeZoom(2, 1), - m.nextLoupeZoom(6, -1), - m.nextLoupeZoom(1.5, 1), + m.nextLoupeZoom(2, -1), + m.nextLoupeZoom(6, 1), + m.nextLoupeZoom(1.5, -1), ], }; // 길게 누름으로 켠 동안도 같음 const b = setup(); b.win.fire("mousedown", { button: 2, buttons: 2 }); b.win.tick(400); - const press = b.win.fire("wheel", { deltaY: -1 }); + const press = b.win.fire("wheel", { deltaY: 1 }); r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) }; } { @@ -298,7 +300,7 @@ r.origin = { // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 const { win, view } = setup(); win.fire("keydown", { code: "KeyZ" }); - win.fire("wheel", { deltaY: -100 }); // 2.25 + win.fire("wheel", { deltaY: 100 }); // 2.25 win.fire("mouseover", { target: cad }); const hidden = last(view); const n = view.log.length; diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a1f70e5b9..5e95ef67b 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -102,7 +102,8 @@ tools.createPanelTools({ /** 누른 자리 — 종단도 안이면 closest 가 그 판을 줌(새 창 요소처럼 Element 가 아닌 것도). */ const spot = (inProfile) => ({ - closest: (sel) => (inProfile && sel === ".b05-route-profile" ? {} : null), + closest: (sel) => + inProfile && sel.split(",").some((s) => s.trim() === ".b05-route-profile") ? {} : null, }); const press = (target) => listeners.document.pointerdown?.forEach((fn) => fn({ target })); diff --git a/resources/tester/helper_224_1a_drainage_rehome.cjs b/resources/tester/helper_224_1a_drainage_rehome.cjs new file mode 100644 index 000000000..1f3bbea85 --- /dev/null +++ b/resources/tester/helper_224_1a_drainage_rehome.cjs @@ -0,0 +1,99 @@ +/* 224-1a 배수 판 창구(rehome) — 진짜 `observeViewportSize`(B05_Profile_UI_Drainage_Parts.ts)를 + * 가짜 창 둘 위에서 돌려 JSON 으로 냄. + * · 처음 = 판이 놓인 창(메인)의 관찰자 + * · rehome = 옛 관찰자 끊고 지금 놓인 창(새 창)의 관찰자로 · 직전 크기 이어받아 배율 유지 + * · disconnect = 지금 관찰자 끊음 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const stub = new Proxy({}, { get: () => () => "" }); + +/** TS 한 장을 CommonJS 로 — 상대 경로는 진짜(공용 창 · 지도 셈만), 나머지는 빈 함수. */ +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + /common_util_view_window|B04_PreProcess_UI_MapRender/.test(request) + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : stub; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/** 가짜 창 — 관찰자를 모아 두고 크기를 바꿔 울림. */ +function fakeWindow(name) { + const win = { name, observers: [] }; + win.ResizeObserver = class { + constructor(fn) { + this.fn = fn; + this.live = false; + win.observers.push(this); + } + observe() { + this.live = true; + } + disconnect() { + this.live = false; + } + }; + return win; +} +const main = fakeWindow("main"); +const popup = fakeWindow("popup"); +global.window = main; + +const parts = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts")); +const doc = { defaultView: main }; +let size = { width: 400, height: 300 }; +const viewport = { ownerDocument: doc, getBoundingClientRect: () => ({ ...size }) }; +const fire = (win) => win.observers.filter((o) => o.live).forEach((o) => o.fn()); + +let view = { scale: 2, offsetX: 10, offsetY: -5 }; +const applied = []; +let redraws = 0; +const META = { + x_min: 0, + x_max: 4000, + y_min: 0, + y_max: 3000, + width_meters: 4000, + height_meters: 3000, +}; +const watcher = parts.observeViewportSize(viewport, { + meta: () => META, + anchor: () => ({ width: 0, height: 0, ...view }), + apply: (next) => (applied.push(next), (view = next)), + redraw: () => (redraws += 1), +}); +const out = { firstOwner: main.observers.length === 1 && popup.observers.length === 0 }; +fire(main); // 처음 크기 기억 +out.firstApplied = applied.length; + +// 새 창으로 옮김 — 메인 관찰자 끊고 새 창 관찰자로 +doc.defaultView = popup; +watcher.rehome(); +out.mainLive = main.observers.some((o) => o.live); +out.popupLive = popup.observers.filter((o) => o.live).length; +size = { width: 800, height: 600 }; // 새 창에서 두 배로 커짐 +fire(popup); +out.afterGrow = { applied: applied.length, ...view }; +// 메인 창 관찰자가 울려도(이미 끊김) 아무 일 없음 +fire(main); +out.mainIgnored = applied.length; + +// 제자리 — 다시 메인 +doc.defaultView = main; +watcher.rehome(); +out.backPopupLive = popup.observers.some((o) => o.live); +out.backMainLive = main.observers.filter((o) => o.live).length; +watcher.disconnect(); +out.afterDispose = [...main.observers, ...popup.observers].some((o) => o.live); +out.redraws = redraws; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_224_1b_masshaul_docked.cjs b/resources/tester/helper_224_1b_masshaul_docked.cjs new file mode 100644 index 000000000..2c07b616a --- /dev/null +++ b/resources/tester/helper_224_1b_masshaul_docked.cjs @@ -0,0 +1,112 @@ +/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 + * (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄. + * · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀 + * · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + +// ---- 가짜 요소(부모 사슬 · contains · closest) ---- +function el(className = "") { + const node = { className, parent: null, hidden: false, clientHeight: 0, offsetHeight: 0 }; + node.style = { + height: "", + setProperty: (k, v) => (node.style[k] = v), + }; + node.append = (kid) => ((kid.parent = node), kid); + node.contains = (other) => { + for (let at = other; at; at = at.parent) if (at === node) return true; + return false; + }; + node.closest = (selector) => { + const wanted = selector.split(",").map((s) => s.trim().replace(/^\./, "")); + for (let at = node; at; at = at.parent) + if (wanted.some((c) => at.className.split(/\s+/).includes(c))) return at; + return null; + }; + return node; +} + +// ---- 진짜 높이 나눔 ---- +const heightsExports = {}; +new Function( + "exports", + "require", + "window", + transpile(read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts")), +)( + heightsExports, + (id) => + id.includes("MassHaul") + ? { MASSHAUL_MIN_HEIGHT: 160 } + : id.includes("TableOverlay") + ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } + : id.includes("Structures_Marks") + ? { STRUCTURE_LANE_HEIGHT_PX: 40 } + : { frameOf: () => 0 }, + { innerHeight: 900 }, +); + +function run(docked) { + const root = el("b05-route-profile"); + root.offsetHeight = 500; + const body = root.append(el()); + body.clientHeight = 400; + const overlay = el("b05-profile__masshaul-overlay"); + if (docked) body.append(overlay); + else el("popup-body").append(overlay); // 새 창 body + const cascade = heightsExports.createHeightCascade({ + root, + body, + massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} }, + tableOverlay: { + overlay: el(), + isOpen: () => false, + desiredHeight: () => 0, + setBottomOffset() {}, + }, + isMainDragging: () => false, + isSubDragging: () => false, + mainDragRef: () => null, + isMainDragCooldown: () => true, // 자동 키우기 끔 — 나눔만 봄 + hasDetail: () => true, + }); + const { chartHeight } = cascade.apply(false); + return { chartHeight, inline: overlay.style.height }; +} + +// ---- 진짜 방향키 판정 본문 ---- +const base = read("B06_Section", "B06_Section_UI_Cross_Panel_Base.ts"); +const at = base.indexOf("export function arrowsBelongToProfile"); +const fnSrc = base.slice(at, base.indexOf("\n}", at) + 2).replace("export ", ""); +const arrows = new Function( + `let lastPointer; ${transpile(fnSrc)}; return (p) => ((lastPointer = p), arrowsBelongToProfile());`, +)(); +const profileRoot = el("b05-route-profile"); +const profileChild = profileRoot.append(el("b05-profile__chart")); +const massPopped = el("b05-profile__masshaul-overlay is-popout"); +const massChild = massPopped.append(el("b05-profile__masshaul-scroll")); +const drainPopped = el("b05-drainage is-popout"); +const drainChild = drainPopped.append(el("b05-drainage__canvas")); +const card = el("b06-cross-card"); + +process.stdout.write( + JSON.stringify({ + docked: run(true), + popped: run(false), + arrows: { + profile: arrows(profileChild), + mass: arrows(massChild), + drainage: arrows(drainChild), + card: arrows(card), + none: arrows(null), + }, + }), +); diff --git a/resources/tester/helper_224_1c_subpanel_popout.cjs b/resources/tester/helper_224_1c_subpanel_popout.cjs new file mode 100644 index 000000000..c48685322 --- /dev/null +++ b/resources/tester/helper_224_1c_subpanel_popout.cjs @@ -0,0 +1,215 @@ +/* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout` + * (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄. + * · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름 + * · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome + * · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝) + * · 종단 새 창 안의 단추 = 그 창에서 엶 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); + +// ---- 가짜 DOM — 부모 사슬 끝이 어느 문서 body 냐로 ownerDocument 를 정함 ---- +let mainDoc = null; +function el(tag, className = "") { + const node = { + tagName: tag.toUpperCase(), + className, + textContent: "", + title: "", + type: "", + hidden: false, + children: [], + parent: null, + listeners: {}, + style: { height: "", cssText: "" }, + }; + node.classList = { + list: () => node.className.split(/\s+/).filter(Boolean), + contains: (c) => node.classList.list().includes(c), + add: (c) => + !node.classList.contains(c) && (node.className = [...node.classList.list(), c].join(" ")), + remove: (c) => + (node.className = node.classList + .list() + .filter((x) => x !== c) + .join(" ")), + }; + Object.defineProperty(node, "parentElement", { get: () => node.parent }); + Object.defineProperty(node, "ownerDocument", { + get: () => { + let top = node; + while (top.parent) top = top.parent; + return top.doc ?? mainDoc; + }, + }); + node.detach = () => { + if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node); + node.parent = null; + }; + node.append = (...kids) => + kids.forEach((k) => (k.detach(), (k.parent = node), node.children.push(k))); + node.prepend = (...kids) => + kids.reverse().forEach((k) => (k.detach(), (k.parent = node), node.children.unshift(k))); + node.replaceChildren = (...kids) => { + node.children.slice().forEach((c) => c.detach()); + node.append(...kids); + }; + node.before = (other) => { + const parent = node.parent; + other.detach(); + other.parent = parent; + parent.children.splice(parent.children.indexOf(node), 0, other); + }; + node.querySelector = (sel) => { + const cls = sel.replace(/^\./, ""); + for (const kid of node.children) { + if (kid.classList.contains(cls)) return kid; + const deep = kid.querySelector(sel); + if (deep) return deep; + } + return null; + }; + node.addEventListener = (type, fn) => (node.listeners[type] ??= []).push(fn); + node.click = () => (node.listeners.click ?? []).forEach((fn) => fn({})); + return node; +} +function fakeDoc(view) { + const doc = { title: "", defaultView: view }; + doc.head = el("head"); + doc.body = el("body"); + doc.body.doc = doc; + doc.documentElement = Object.assign(el("html"), { attributes: [], setAttribute() {} }); + doc.querySelectorAll = () => []; + doc.importNode = (n) => n; + doc.createElement = (tag) => el(tag); + return doc; +} +const opened = []; // [여는 창 이름, 새 창 이름] +function fakeWindow(label) { + const win = { label, listeners: {}, closed: false, frames: 0 }; + win.document = fakeDoc(win); + win.addEventListener = (t, fn) => (win.listeners[t] ??= []).push(fn); + win.removeEventListener = () => {}; + win.getComputedStyle = () => ({ backgroundColor: "" }); + win.requestAnimationFrame = () => (win.frames += 1); + win.cancelAnimationFrame = () => {}; + win.ResizeObserver = class { + observe() {} + }; + win.close = () => (win.closed = true); + win.fire = (t) => (win.listeners[t] ?? []).forEach((fn) => fn({})); + win.open = (_url, name) => { + const next = fakeWindow(name); + opened.push([label, name]); + return next; + }; + return win; +} +const main = fakeWindow("main"); +mainDoc = main.document; + +const js = ts.transpileModule(read("A00_Common", "design", "design_profile_popout.ts"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const mod = {}; +new Function("exports", "require", "window", "document", js)( + mod, + () => ({ showToast() {} }), + main, + mainDoc, +); + +// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ---- +const slot = el("div"); +mainDoc.body.append(slot); +const root = el("div", "b05-route-profile"); +slot.append(root); +const content = el("div", "b05-route-profile__content"); +const bodyWrap = el("div"); +const massOverlay = el("div", "b05-profile__masshaul-overlay"); +const massHandle = el("div", "b05-profile__masshaul-handle"); +bodyWrap.append(massOverlay, massHandle); +const drainageRoot = el("aside", "b05-drainage"); +const header = el("div", "b05-drainage__header"); +header.append(el("h3")); +drainageRoot.append(header); +content.append(bodyWrap, drainageRoot); +root.append(content); +let rehomes = 0; +let disposed = 0; +const profile = { + root, + redraw() {}, + dispose: () => (disposed += 1), + side: { root: drainageRoot, rehome: () => (rehomes += 1) }, +}; +const profileButton = el("button"); +mod.attachProfilePopout(profile, slot, profileButton); +mod.attachSubPanelPopouts(profile); + +const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON"); +const drainButton = header.children[0]; +const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type }); +const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) }; + +// 유토 뗌 +massOverlay.style.height = "123px"; +massButton.click(); +const massPopup = massOverlay.parent?.doc?.defaultView; +out.massOpened = { + inPopupBody: massOverlay.parent === massPopup?.document.body, + isPopout: massOverlay.classList.contains("is-popout"), + handleHidden: massHandle.hidden, + inline: massOverlay.style.height, + text: massButton.textContent, + docTitle: massPopup?.document.title, +}; +// 배수 뗌 +drainButton.click(); +const drainPopup = drainageRoot.parent?.doc?.defaultView; +out.drainOpened = { + inPopupBody: drainageRoot.parent === drainPopup?.document.body, + rehomes, + text: drainButton.textContent, +}; +// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞) +massPopup.fire("pagehide"); +out.massClosed = { + order: bodyWrap.children.map((c) => c.className), + handleHidden: massHandle.hidden, + isPopout: massOverlay.classList.contains("is-popout"), + text: massButton.textContent, +}; +// 배수 단추 다시 누름 → 창 닫고 content 끝 +drainButton.click(); +out.drainClosed = { + popupClosed: drainPopup.closed, + last: content.children[content.children.length - 1] === drainageRoot, + rehomes, + text: drainButton.textContent, +}; + +// 종단 새 창 → 그 안에서 유토 뗌 = 종단 새 창이 엶 +profileButton.click(); +const profilePopup = root.parent?.doc?.defaultView; +massButton.click(); +const massPopup2 = massOverlay.parent?.doc?.defaultView; +out.nested = { profileInPopup: !!profilePopup && profilePopup !== main }; +// 페이지 떠남(dispose) → 셋 다 닫히고 제자리 +drainButton.click(); +const drainPopup2 = drainageRoot.parent?.doc?.defaultView; +profile.dispose(); +out.disposed = { + closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed], + profileInSlot: root.parent === slot, + massBeforeHandle: + bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1, + drainInContent: drainageRoot.parent === content, + handleHidden: massHandle.hidden, + original: disposed, +}; +out.opened = opened; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs index a5dfdbdbf..9b02f78cb 100644 --- a/resources/tester/helper_266_detail_home.cjs +++ b/resources/tester/helper_266_detail_home.cjs @@ -28,6 +28,7 @@ function loadTs(full) { new Function("exports", "module", "require", js)(box.exports, box, localRequire); return box.exports; } +STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts")); // 휠 방향(332 · Node = 기본 당김) const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); diff --git a/resources/tester/helper_282_1_profile_toolbar.cjs b/resources/tester/helper_282_1_profile_toolbar.cjs index e24598ca9..1fe0671b6 100644 --- a/resources/tester/helper_282_1_profile_toolbar.cjs +++ b/resources/tester/helper_282_1_profile_toolbar.cjs @@ -84,6 +84,9 @@ const window = { addEventListener: (t, fn) => (window.listeners[t] ??= []).push(fn), removeEventListener: () => {}, open: () => popup, + document, + getComputedStyle: () => ({ backgroundColor: "" }), + requestAnimationFrame: (fn) => frames.push(fn), }; const popupListeners = {}; const popup = { diff --git a/resources/tester/helper_input_prefs.cjs b/resources/tester/helper_input_prefs.cjs new file mode 100644 index 000000000..860064bb5 --- /dev/null +++ b/resources/tester/helper_input_prefs.cjs @@ -0,0 +1,63 @@ +/* PLAN 332-1 단위검증 헬퍼 — 휠 방향 읽기 층(`A00_Common/input_prefs.ts`) · 손가락 다리(`touch_bridge.ts`) + * · 등록표 키(`b_page_state.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림. 가짜 localStorage 로 설정을 바꿔 봄. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_input_prefs.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const A00 = path.join(ROOT, "A00_Common"); +function load(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const req = (p) => (p === "./input_prefs" ? prefs : {}); + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} + +const out = {}; +// ① Node(저장소 없음) — 불러올 때 window 를 안 만지고 기본값 +const prefs = load(path.join(A00, "input_prefs.ts")); +const bridge = load(path.join(A00, "touch_bridge.ts")); +const page = load(path.join(A00, "b_page_state.ts")); +const sample = () => ({ + prefs: prefs.inputPrefs(), + pull: prefs.wheelIn(100), + push: prefs.wheelIn(-100), + mouseCtrl: prefs.wheelEventIn({ ctrlKey: true, deltaY: 100 }), + padSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -3.5 }), + padPinch: prefs.wheelEventIn({ ctrlKey: true, deltaY: 3.5 }), + fingerSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -9.5 }, true), + bridgePull: bridge.wheelZoomsIn({ ctrlKey: false, deltaY: 100 }), +}); +out.node = { key: prefs.INPUT_PREFS_KEY, default: prefs.DEFAULT_INPUT_PREFS, ...sample() }; +out.registryKey = page.stateKey("input-prefs"); +out.registryEntry = page.STATE_REGISTRY["input-prefs"]; + +// ② 브라우저 저장소 흉내 — 값을 바꾸면 다음 부름부터 바로 따름 +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; +const set = (v) => store.set(prefs.INPUT_PREFS_KEY, v); +set(JSON.stringify({ wheelZoom: "push", cadAliases: { line: ["ll", 3], ARC: [] } })); +out.push = sample(); +set("{깨짐"); +out.broken = sample(); +set(JSON.stringify({ wheelZoom: "sideways" })); +out.unknown = sample(); +store.delete(prefs.INPUT_PREFS_KEY); +out.empty = sample(); +// ③ 불러오는 동안 저장소가 던지는 브라우저(사생활 보호) — 기본값 +globalThis.localStorage = { + getItem: () => { + throw new Error("blocked"); + }, +}; +out.blocked = sample(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index a061c282c..78c8b60a9 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -1,6 +1,7 @@ /* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · - * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · - * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */ + * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 + * 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · + * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -55,6 +56,13 @@ function loadTs(file) { await pick.pickHopyo(input.rows[0], lib); const project = pick.withHopyo({ ...lib, layer: "project" }); const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); + await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001"); + const spans = [ + { 번: 3, 이름: "임도표지석", 행: [20, 24] }, + { 번: 4, 이름: "배수관", 행: [25, 30] }, + ]; const out = { calls, kinds: wrapped.kindFilter.map((k) => k.value), @@ -80,6 +88,40 @@ function loadTs(file) { view.builtWord({ 다시씀: true }, true), ], }, + basis: (() => { + const hit = { + 조항: ["건설 공통 3-2-1"], + 직종: ["특별인부"], + 단위: true, + 점수: 1.65, + 까닭: "조항", + }; + const half = { ...hit, 직종: [] }; + const line = (상태, 대상, 후보) => ({ 맞춤: { 상태, 대상, 후보 } }); + const a = { 로직: "GF000101", 근거: half }; + const b = { 로직: "GC000083", 근거: hit }; + return { + ok: [hit, half, { ...hit, 단위: false }, undefined].map(view.basisOk), + tag: [hit, half, undefined].map(view.basisTag), + source: [ + view.sourceText({ 출처: [["건설표준품셈", "3-2-1"]], 직종: ["특별인부"], 행: [93, 101] }), + view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }), + view.sourceText(undefined), + ], + pick: [ + line("후보", a, [a, b]), + line("후보", b, [b]), + line("같음", b, [b]), + line("후보", a, [a]), + ].map((x) => view.basisPick(x)?.로직 ?? null), + }; + })(), + sheet: { + at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)), + span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })), + last: [sheet.lastColumn({ 칸: { Z9: {}, AA1: {} } }), sheet.lastColumn({ 칸: {} })], + rows: [sheet.rowsText(4345), sheet.rowsText([93, 101])], + }, }; process.stdout.write(JSON.stringify(out)); })().catch((error) => { diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index e93264998..792c7c4a1 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -65,6 +65,11 @@ const out = { })(), cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0), usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c), + // 손잡이 select(범위) — 범위 글 → 고름(병합 늘림 · 여럿 · 틀린 글 null) + ranges: [["B20:O24"], ["D4"], ["B20:O24", "A1:A2"], ["가1"], []].map((t) => { + const s = sel.selectRanges(s1, t); + return s && [addr.toA1(s.활성.r, s.활성.c), ...s.범위.map(addr.rangeToA1)].join("|"); + }), // 입력 글 → 칸 textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map( (t) => ed.textToCell(t, { 서식: 4 }), diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index ebd4f575b..4f8252708 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -46,6 +46,11 @@ def test_cycle_in_selection(r): assert r["cycleSingle"] is None +def test_select_ranges(r): + # 엑셀 보기 창(329-2) 호표 영역 강조 — 활성 = 왼위 · 병합 C4:F4 · A1:H1 걸치면 늘림 · 틀린 글 · 빈 목록 null + assert r["ranges"] == ["B20|B20:O24", "C4|C4:F4", "B20|B20:O24|A1:H2", None, None] + + def test_text_to_cell(r): got = r["textToCell"] assert [c.get("값", c.get("식")) for c in got] == [ diff --git a/resources/tester/test_220_1_loupe_wheel.py b/resources/tester/test_220_1_loupe_wheel.py index a1c5bda7d..e70b9c4da 100644 --- a/resources/tester/test_220_1_loupe_wheel.py +++ b/resources/tester/test_220_1_loupe_wheel.py @@ -34,10 +34,10 @@ def test_지름_360_배율_한도(r): assert (c["zoom"], c["zoomMin"], c["zoomMax"], c["zoomStep"]) == (2, 1.5, 6, 0.25) -def test_휠_위_크게_아래_작게_한도(r): +def test_휠_당김_크게_밀기_작게_한도(r): # 332 기본 = 당김 확대 w = r["wheel"] - assert w["up"]["zoom"] == 2.25 # 위로 한 칸 - assert w["down"] == 1.75 # 아래로 두 칸 + assert w["up"]["zoom"] == 2.25 # 당겨 한 칸 + assert w["down"] == 1.75 # 밀어 두 칸 assert w["flat"] == 1.75 # 가로 굴림은 그대로 assert w["max"] == 6 and w["min"] == 1.5 assert w["pure"] == [2.25, 1.75, 6, 1.5] diff --git a/resources/tester/test_224_1a_drainage_rehome.py b/resources/tester/test_224_1a_drainage_rehome.py new file mode 100644 index 000000000..c53de3f95 --- /dev/null +++ b/resources/tester/test_224_1a_drainage_rehome.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""224-1a(PLAN 325-1) 배수유역 판 창구 — 새 창으로 떼어도 지도 크기 관찰이 그 창을 따름. + +- 판 지도 크기 관찰(`observeViewportSize`)은 판이 놓인 창의 ResizeObserver 를 씀. +- `rehome()` = 옛 창 관찰자 끊고 지금 놓인 창 것으로 · 직전 크기를 이어받아 배율(화면 m 당 px) 유지. +- 판 창구(`DrainagePanel.rehome`)로 내보내고 패널 본체는 700줄 안. + +`helper_224_1a_drainage_rehome.cjs` 가 진짜 TS 를 가짜 창 둘 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1a_drainage_rehome.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음은_놓인_창_관찰자(r): + assert r["firstOwner"] is True + assert r["firstApplied"] == 0 # 처음 크기는 기억만 + + +def test_rehome_은_새_창_관찰자로(r): + assert r["mainLive"] is False + assert r["popupLive"] == 1 + + +def test_새_창에서_커져도_배율_유지(r): + """400 → 800 px · 배율 2 → 1 = 화면 m 당 px 그대로.""" + grow = r["afterGrow"] + assert grow["applied"] == 1 + assert grow["scale"] == pytest.approx(1) + assert r["mainIgnored"] == 1 # 끊긴 메인 관찰자는 안 울림 + + +def test_제자리와_정리(r): + assert r["backPopupLive"] is False + assert r["backMainLive"] == 1 + assert r["afterDispose"] is False + assert r["redraws"] == 2 + + +def test_판_창구와_700줄(): + panel = (B05 / "B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8") + assert "rehome: () => resizeObserver.rehome()," in panel + assert len(panel.splitlines()) <= 700 + types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8") + assert "rehome: () => void;" in types diff --git a/resources/tester/test_224_1b_masshaul_docked.py b/resources/tester/test_224_1b_masshaul_docked.py new file mode 100644 index 000000000..d537ad087 --- /dev/null +++ b/resources/tester/test_224_1b_masshaul_docked.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키. + +- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 · + 임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게). +- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로. +- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫. + +`helper_224_1b_masshaul_docked.cjs` 가 진짜 TS 를 가짜 DOM 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1b_masshaul_docked.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_붙은_유토는_자리를_차지(r): + """본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100.""" + assert r["docked"] == {"chartHeight": 100, "inline": "247px"} + + +def test_뗀_유토는_자리_0_인라인_없음(r): + assert r["popped"] == {"chartHeight": 347, "inline": ""} + + +def test_방향키_판정(r): + assert r["arrows"] == { + "profile": True, + "mass": True, + "drainage": True, + "card": False, + "none": False, + } + + +def test_끌기도_같은_판정(): + drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8") + assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만 + assert drag.count("massHaul.overlay.hidden") == 1 + assert drag.count("massDocked()") == 3 + assert "root.contains(massHaul.overlay)" in drag + + +def test_뗀_유토는_노선_전체_세로(): + mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + assert re.search( + r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange', + mass, + ) diff --git a/resources/tester/test_224_1c_subpanel_popout.py b/resources/tester/test_224_1c_subpanel_popout.py new file mode 100644 index 000000000..df926a112 --- /dev/null +++ b/resources/tester/test_224_1c_subpanel_popout.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창. + +- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. +- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐). +- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome. +- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝. +- 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함). + +`helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +NAMES = ["aislo-profile-popout", "aislo-masshaul-popout", "aislo-drainage-popout"] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1c_subpanel_popout.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_단추_글_툴팁_클래스(r): + cls = "ui-btn ui-btn--ghost b05-subpanel__popout" + assert r["mass"] == { + "text": "창 분할", + "title": "유토곡선 창 분할", + "className": cls, + "type": "button", + } + assert r["drainage"] == { + "text": "창 분할", + "title": "배수유역 창 분할", + "className": cls, + "type": "button", + } + assert "b05-route-profile__popout" in r["profile"]["className"] + + +def test_유토_뗌(r): + assert r["massOpened"] == { + "inPopupBody": True, + "isPopout": True, + "handleHidden": True, + "inline": "", + "text": "창 결합", + "docTitle": "유토곡선 — Aislo", + } + + +def test_배수_뗌은_rehome(r): + assert r["drainOpened"] == {"inPopupBody": True, "rehomes": 1, "text": "창 결합"} + + +def test_새_창_닫힘과_단추로_제자리(r): + assert r["massClosed"] == { + "order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"], + "handleHidden": False, + "isPopout": False, + "text": "창 분할", + } + assert r["drainClosed"] == {"popupClosed": True, "last": True, "rehomes": 2, "text": "창 분할"} + + +def test_창_이름_셋_다름과_여는_창(r): + names = [name for _, name in r["opened"]] + assert set(names) == set(NAMES) and len(set(NAMES)) == 3 + # 종단 새 창 안의 단추는 종단 새 창이 엶 + assert r["opened"][3] == ["aislo-profile-popout", "aislo-masshaul-popout"] + assert r["nested"]["profileInPopup"] is True + + +def test_페이지_떠나면_셋_다_닫고_제자리(r): + assert r["disposed"] == { + "closed": [True, True, True], + "profileInSlot": True, + "massBeforeHandle": True, + "drainInContent": True, + "handleHidden": False, + "original": 1, + } + + +def test_배선과_양식(): + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + assert "attachSubPanelPopouts(profile);" in shared + css = (ROOT / "A00_Common/design/design_page.css").read_text(encoding="utf-8") + block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1) + assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block + assert ".b05-profile__masshaul-overlay.is-popout," in css + assert ".b05-drainage.is-popout {" in css diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py new file mode 100644 index 000000000..2e123b818 --- /dev/null +++ b/resources/tester/test_224_2_station_snap.py @@ -0,0 +1,388 @@ +"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅]. + +짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` · +`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` · +`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 +하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로. +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 + +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +GRID = [float(value) for value in range(0, 1001, 20)] + + +def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]: + return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)] + + +# ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── + + +def test_곡선_없음_그대로() -> None: + assert _snap([]) == GRID + + +def test_측점_없는_호는_안_심음() -> None: + assert _snap([(621.0, 639.0)]) == GRID + + +def test_호_안_하나를_정수_MC_로() -> None: + out = _snap([(612.0, 634.0)]) # MC 623 + assert 623.0 in out and 620.0 not in out and len(out) == len(GRID) + + +def test_호_안_둘이면_MC_에_가까운_하나만() -> None: + out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움 + assert 631.0 in out and 640.0 not in out and 620.0 in out + + +def test_BC_위_측점은_호_밖() -> None: + assert _snap([(620.0, 630.0)]) == GRID + assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖 + + +def test_반_올림() -> None: + out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖 + assert 626.0 in out and 620.0 not in out and 640.0 in out + + +def test_같은_m_는_하나로() -> None: + # 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음 + assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0] + + +# ── 파이썬 ↔ TS 거울 ───────────────────────────────────────────────────────── + + +def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list: + return [ + [cx + radius * math.cos(angle), cy + radius * math.sin(angle)] + for angle in np.linspace(start, end, count) + ] + + +def _bend_route() -> tuple[list, list]: + """직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선.""" + arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60) + line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]] + curves = [ + {"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0}, + {"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0}, + {"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0}, + {"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None}, + ] + return line, curves + + +CASES = [ + {"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []}, + { + "line": [[0, 0], [1000.3, 0]], + "interval": 20, + "end": True, + "curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}], + }, + { + "line": [[0, 0], [500, 0], [500, 0], [700, 10]], + "interval": 20, + "end": False, + "curves": [ + {"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25}, + {"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25}, + {"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25}, + ], + }, + {"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]}, + {"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]}, +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { + tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M, + edge: frame.CURVE_STATION_EDGE_M, + rows: cases.map((item) => { + const grid = frame.stationsAlong(item.line, item.interval, item.end); + const spans = frame.curveSpans(item.line, item.curves); + return { grid, spans, snapped: frame.snapCurveStations(grid, spans) }; + }), +}; +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _python_row(case: dict) -> dict: + points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64)) + grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"]) + spans = core._design_curve_spans(points, route_chainage, case["curves"]) + return { + "grid": [float(v) for v in grid], + "spans": [[span[0], span[1]] for span in spans], + "snapped": [float(v) for v in core.snap_curve_stations(grid, spans)], + } + + +def _ts_rows(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.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") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None: + actual = _ts_rows(tmp_path) + assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M + assert actual["edge"] == core.CURVE_STATION_EDGE_M + moved = 0 + for index, case in enumerate(CASES): + expected = _python_row(case) + row = actual["rows"][index] + assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자" + assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수" + for got, want in zip(row["spans"], expected["spans"], strict=True): + assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선" + assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅" + moved += row["snapped"] != row["grid"] + assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌 + + +def test_짝_머리글이_서로를_가리킨다() -> None: + text = TS_FILE.read_text(encoding="utf-8") + assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text + assert "snapCurveStations" in (core.__doc__ or "") + + +# ── generate_sections ──────────────────────────────────────────────────────── + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +_LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]] +_CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}] + + +def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]: + kwargs = {} if snap is None else {"station_snap": snap} + result = core.generate_sections( + _LINE, + _Flat(), + core.SectionGenerationOptions(extra_stations=extras), + design_curves=_CURVES, + **kwargs, + ) + return result["longitudinal"]["stations"] + + +def test_끔은_지금과_같음() -> None: + assert _stations(False) == _stations(None) + assert 640.0 in [s["chainage_m"] for s in _stations(False)] + + +def test_켬은_640_없고_631_있음() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(True)} + assert 640.0 not in by_chainage and 631.0 in by_chainage + assert by_chainage[631.0]["kind"] == "regular" + assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 + assert len(by_chainage) == len(_stations(False)) + + +def test_631_에_관이면_grid() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))} + assert by_chainage[631.0]["kind"] == "irregular" + assert by_chainage[631.0]["grid"] is True + # 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점 + off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))} + assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0] + + +# ── 서버 저장 · 정본 (326-2) ───────────────────────────────────────────────── + +_NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)] + + +def _working(tmp_path: Path) -> Path: + from common_util.common_util_route_geometry import planned_route_working_path + + path = planned_route_working_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None: + from B05_Profile import B05_Profile_Router_Replan as replan + from B05_Profile.B05_Profile_Engine_Sections import load_station_snap + + path = _working(tmp_path) + assert load_station_snap(tmp_path) is False # 파일 없음 + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8")) + assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) + assert load_station_snap(tmp_path) is True + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False) + assert load_station_snap(tmp_path) is False + + +def _patch_replan(monkeypatch, tmp_path: Path): + from B05_Profile import B05_Profile_Router_Replan as replan + + async def paths(_project_id): + return tmp_path, "stored" + + async def criteria(_project_id): + return 30.0, 0.0, 0.0 + + monkeypatch.setattr(replan, "_project_paths", paths) + monkeypatch.setattr(replan, "_plan_criteria", criteria) + return replan + + +def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None: + import asyncio + from uuid import uuid4 + + replan = _patch_replan(monkeypatch, tmp_path) + + async def broken(*_args, **_kwargs): + return {"error": "체인 끊김"} + + monkeypatch.setattr(replan, "_recompute", broken) + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + files = (path, replan._curves_path(path), replan._nodes_path(path)) + before = {file: file.read_bytes() for file in files} + request = replan.RouteReplanRequest( + vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}], + station_snap=True, + ) + response = asyncio.run(replan.replan_route(uuid4(), request)) + assert response.status_code == 409 + assert {file: file.read_bytes() for file in files} == before + + +def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None: + import asyncio + from uuid import uuid4 + + replan = _patch_replan(monkeypatch, tmp_path) + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) + assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True + + +def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Engine_Sections as sections + from B05_Profile import B05_Profile_Router_Replan as replan + + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + replan._curves_path(path).write_text( + json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8" + ) + monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE) + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat()) + # [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리 + stations, _ = sections.sample_structure_sections( + tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),) + ) + assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True + + seen: dict = {} + + def stop(*_args, **kwargs): + seen.update(kwargs) + raise RuntimeError("여기까지") + + monkeypatch.setattr(sections, "generate_sections", stop) + monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: []) + monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ()) + with pytest.raises(RuntimeError): + sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False) + assert seen["station_snap"] is True + + +def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Router_Terrain as terrain + from common_util.common_util_route_polyline import build_planned_polyline + + monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat()) + built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False) + points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64)) + spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves]) + assert len(spans) == 1 + target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5) + assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음 + + def ask(snap: bool) -> float: + request = terrain.CrossPreviewRequest( + vertices=[{"x": x, "y": y} for x, y in _NODES], + chainage_m=target, + min_radius_m=40.0, + station_interval_m=20.0, + station_snap=snap, + ) + params = {"source_filter": "f", "method": "m", "smooth": False} + return terrain._cross_preview(tmp_path, params, request)["chainage_m"] + + assert ask(True) == target + assert ask(False) % 20 == 0 diff --git a/resources/tester/test_224_3_recommend_store.py b/resources/tester/test_224_3_recommend_store.py new file mode 100644 index 000000000..528f34605 --- /dev/null +++ b/resources/tester/test_224_3_recommend_store.py @@ -0,0 +1,231 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3) — `B05_Profile_Engine_RouteInitial_Store`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_224_3_recommend_store.py -q + +같은 입력 = 같은 키 · 하나만 바뀌어도 다른 키 · 두 번째 요청 셈 0회 · 덮어쓰기 없음 · +[초기화](스냅샷 복원) 뒤에도 남음 · 자료 교체 때 지워짐 · 덮개 수는 꺼낼 때 지금 관으로. +엔드포인트 몸통(`compute_initial`)은 DB · 지형 읽기만 갈아 끼우고 그대로 돌린다. +""" + +import asyncio +import json +import shutil +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import numpy as np +import pytest + +from B05_Profile import B05_Profile_Router_Replan as replan +from B05_Profile import B05_Profile_Router_RouteInitial as router +from B05_Profile.B05_Profile_Engine_RouteInitial import STREAM_OFFSET_M, Terrain +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + DEFAULT_MODES, + request_fingerprint, + store_dir, + write_stored, +) +from common_util.common_util_initial_snapshot import ( + _FILE_TREES, + restore_snapshot_files, + snapshot_dir, +) +from common_util.common_util_project_reset import purge_output_folders + + +def _terrain(lift: float = 0.0) -> Terrain: + x = np.arange(0.0, 40.0, 2.0) + y = np.arange(0.0, 20.0, 2.0) + xx, yy = np.meshgrid(x, y) + return Terrain(x, y, 0.1 * xx + lift, np.ones(xx.shape, dtype=bool)) + + +EXPECTED = [(0.0, 10.0), (20.0, 10.0), (38.0, 10.0)] +STREAMS = [[(5.0, 0.0), (5.0, 18.0)]] +BASE = { + "mode": "weighted", + "weights": None, + "max_grade": 0.14, + "min_radius_m": 12.0, + "stream_offset_m": None, + "grade_smooth_m": None, + "target_grade": None, + "with_profile": True, + "design_speed": 20, +} + + +def _key(expected=EXPECTED, terrain=None, streams=STREAMS, **changes) -> str: + return request_fingerprint(expected, terrain or _terrain(), streams, **{**BASE, **changes})[0] + + +def test_same_input_same_key() -> None: + assert _key() == _key() + # 비운 값 = 기본값 · 비중 None = 50/25/25 — 화면이 채워 보내도 같은 키 + assert _key() == _key(stream_offset_m=STREAM_OFFSET_M) + assert _key() == _key(weights={"expected": 50.0, "earthwork": 25.0, "grade": 25.0}) + # 비중 갈래가 아니면 비중은 갈래가 정함 — 보낸 비중은 키에 안 듦 + assert _key(mode="earthwork") == _key(mode="earthwork", weights={"expected": 1.0}) + + +@pytest.mark.parametrize( + "change", + [ + {"mode": "earthwork"}, + {"weights": {"expected": 40.0, "earthwork": 30.0, "grade": 30.0}}, + {"max_grade": 0.12}, + {"min_radius_m": 15.0}, + {"stream_offset_m": 50.0}, + {"grade_smooth_m": 3.0}, + {"target_grade": 0.08}, + {"with_profile": False}, + {"design_speed": 30}, + {"expected": [(0.0, 10.0), (20.0, 11.0), (38.0, 10.0)]}, + {"terrain": _terrain(lift=0.5)}, + {"streams": []}, + ], + ids=lambda change: next(iter(change)), +) +def test_one_change_other_key(change: dict) -> None: + assert _key(**change) != _key() + + +def test_no_overwrite(tmp_path: Path) -> None: + assert write_stored(tmp_path, "k", "p", {}, {"n": 1}, True) + assert not write_stored(tmp_path, "k", "p", {}, {"n": 2}, False) + data = json.loads((store_dir(tmp_path) / "k.json").read_text(encoding="utf-8")) + assert data["result"] == {"n": 1} and data["default"] is True + assert set(data) == {"version", "key", "pool", "default", "created_at", "request", "result"} + + +def test_survives_reset_and_goes_with_data_swap(tmp_path: Path) -> None: + (tmp_path / "B05_Profile/route").mkdir(parents=True) + (tmp_path / "B05_Profile/route/initial_choice.json").write_text("{}", encoding="utf-8") + write_stored(tmp_path, "k", "p", {}, {}, True) + # 스냅샷 = 작업본 트리 사본(`save_initial_snapshot` 과 같은 자리) + for tree in _FILE_TREES: + source = tmp_path / tree + if source.is_dir(): + shutil.copytree(source, snapshot_dir(tmp_path) / tree.replace("/", "__")) + restore_snapshot_files(tmp_path) + assert (store_dir(tmp_path) / "k.json").is_file() + purge_output_folders(tmp_path) # 자료 교체 + assert not store_dir(tmp_path).exists() + + +class _Calls: + def __init__(self) -> None: + self.count = 0 + + +@pytest.fixture +def wired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Calls: + """`compute_initial` 의 DB · 파일 읽기를 갈아 끼움 · 셈은 횟수만 세는 가짜.""" + import B05_Profile.B05_Profile_Engine_Grade as grade + import B05_Profile.B05_Profile_Engine_RouteInitial as engine + import B05_Profile.B05_Profile_Engine_RouteInitial_Profile as profile + import common_util.common_util_surface_confirmation as surface + + calls = _Calls() + + async def project_paths(_pid): + return tmp_path, "x" + + async def plan_criteria(_pid): + return 12.0, 12.0, 5.0 + + async def road_settings(_pid): + return "간선", 20, "일반" + + async def surface_params(_connection, _pid): + return {} + + class _Pool: + @asynccontextmanager + async def acquire(self): + yield None + + def generate(expected, terrain, streams, *, pipes, **_inputs): + assert pipes is None # 관은 키 밖 — 덮개 없이 셈해 남김 + calls.count += 1 + return { + "planned": [[x, y] for x, y in expected], + "metrics": { + "length_m": 38.0, + "offset_avg_m": 0.0, + "profile": { + "stations_m": [0.0, 38.0], + "plan_m": [1.0, 1.0], + "ground_m": [0.0, 0.0], + }, + }, + } + + monkeypatch.setattr(replan, "_project_paths", project_paths) + monkeypatch.setattr(replan, "_plan_criteria", plan_criteria) + monkeypatch.setattr(replan, "_road_settings", road_settings) + monkeypatch.setattr(replan, "_vertices_of", lambda _path: [list(p) for p in EXPECTED]) + monkeypatch.setattr(router, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params) + monkeypatch.setattr(surface, "analysis_terrain_params", lambda selection, _t: selection) + monkeypatch.setattr(grade, "legal_grade_limit_pct", lambda *_a: 14.0) + monkeypatch.setattr(engine, "load_terrain_and_streams", lambda *_a: (_terrain(), STREAMS)) + monkeypatch.setattr(engine, "generate_initial_route", generate) + # 덮개 여유 2 m 관 하나가 노선 위 — 계획고 − 지반고 1 m 라 모자람 1 + monkeypatch.setattr(profile, "project_pipes", lambda _root: [(19.0, 10.0, 2.0)]) + calls.root = tmp_path + return calls + + +def _ask(**fields) -> dict: + request = replan.RouteInitialRequest(**fields) + return asyncio.run(router.compute_initial(uuid4(), request)) + + +def test_second_request_computes_nothing(wired: _Calls) -> None: + first = _ask(mode="earthwork") + assert wired.count == 1 and first["stored"] is False + second = _ask(mode="earthwork") + assert wired.count == 1 and second["stored"] is True + assert second["planned"] == first["planned"] + # 덮개 수는 꺼낼 때마다 지금 관으로 · 저장본에는 없음 + assert second["metrics"]["profile"]["cover_short"] == 1 + files = list(store_dir(wired.root).glob("*.json")) + assert len(files) == 1 + saved = json.loads(files[0].read_text(encoding="utf-8")) + assert "cover_short" not in saved["result"]["metrics"]["profile"] + assert saved["default"] is True and saved["request"]["mode"] == "earthwork" + # 제약 하나 바꾸면 파일 하나 더 · 옛 파일 그대로 · 기본값 표시 꺼짐 + changed = _ask(mode="earthwork", criteria={"min_radius_m": 15.0}) + assert changed["stored"] is False and wired.count == 2 + assert len(list(store_dir(wired.root).glob("*.json"))) == 2 + assert json.loads(files[0].read_text(encoding="utf-8")) == saved + + +def test_same_pool_in_flight_waits(wired: _Calls) -> None: + async def both() -> list[dict]: + request = replan.RouteInitialRequest(mode="gentle") + project = uuid4() + return await asyncio.gather( + router.compute_initial(project, request), router.compute_initial(project, request) + ) + + results = asyncio.run(both()) + assert wired.count == 1 + assert sorted(result["stored"] for result in results) == [False, True] + + +def test_precompute_defaults_then_hits(wired: _Calls) -> None: + asyncio.run(router.precompute_defaults(uuid4())) + assert wired.count == len(DEFAULT_MODES) == 5 + saved = [ + json.loads(path.read_text(encoding="utf-8")) + for path in store_dir(wired.root).glob("*.json") + ] + assert sorted(item["request"]["mode"] for item in saved) == sorted(DEFAULT_MODES) + assert all(item["default"] for item in saved) + # 화면 첫 누름(비중은 화면이 50/25/25 를 채워 보냄 · 추적은 종단 끔) — 셈 없이 저장본 + hit = _ask(mode="weighted", weights={"expected": 50, "earthwork": 25, "grade": 25}) + follow = _ask(mode="follow", with_profile=False) + assert hit["stored"] and follow["stored"] and wired.count == 5 diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py new file mode 100644 index 000000000..befa3dae8 --- /dev/null +++ b/resources/tester/test_224_4_recommend_radius.py @@ -0,0 +1,156 @@ +"""추천 노선 큰 R(PLAN 328 · 요구 224-4) — 1단 R 맞춤 · 2단 허용 이탈 안 가장 큰 R. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_224_4_recommend_radius.py -q + +· 네 갈래(영선 · 토공 최소 · 기울기 최소 · 비중) R ≥ 최소(모자라면 위반) · 하나 이상 큼. +· 위반 ≤ 같은 노드로 짠 최소 R 판(노드마다 곡선 · 옛 규칙) — 묶음의 잰 노선 전부. +· 예상노선 추적은 전처리 초기값 그대로. +· 2단 = 탐색 경로 이탈 ≤ 허용(4 m) · 1 m 더 키우면 넘음 · [확인] 재구성 뒤 같은 R. +""" + +import math + +import numpy as np +import pytest +from shapely.geometry import LineString, Point + +import B05_Profile.B05_Profile_Engine_RouteInitial as engine +from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen +from common_util.common_util_route_polyline import build_planned_polyline +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +GRADE = 0.14 +RADIUS = 12.0 +SHOWN = ["gradeline", "earthwork", "gentle", "weighted"] + + +def _line() -> list[tuple[float, float]]: + return [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)] + + +@pytest.fixture(scope="module") +def runs() -> dict: + """가운데 언덕(40 m) — 곧은 예상노선이 꼭대기를 넘어 갈래마다 돌아감. 묶음 · 잼도 붙잡음.""" + x = np.arange(0.0, 802.0, 2.0) + y = np.arange(0.0, 402.0, 2.0) + xx, yy = np.meshgrid(x, y) + z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) + terrain = Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) + caught: dict = {} + real = engine.build_pool + + def spy(graph, follow, max_grade, min_radius_m, measure): + caught["measure"] = measure + caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure) + return caught["pool"] + + engine._POOLS.clear() + engine.build_pool = spy + try: + results = { + mode: generate_initial_route( + _line(), terrain, [], mode=mode, weights=None, max_grade=GRADE, min_radius_m=RADIUS + ) + for mode in [*SHOWN, "follow"] + } + finally: + engine.build_pool = real + engine._POOLS.clear() + return {"results": results, **caught} + + +def test_shown_branches_radius_at_least_min_and_some_larger(runs: dict) -> None: + radii = [] + for mode in SHOWN: + for curve in runs["results"][mode]["curves"]: + assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"], mode + radii.append(curve["radius_m"]) + assert max(radii) > RADIUS + 1.0 + + +def test_violations_not_above_min_radius_version(runs: dict) -> None: + measure = runs["measure"] + for (nodes, kw), (_, metrics) in runs["pool"].traced.items(): + floor = build_planned_polyline( + list(nodes), min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(nodes) + ) + least = measure(floor, **dict(kw))["over_limit"]["total"] + assert metrics["over_limit"]["total"] <= least + + +def test_follow_unchanged(runs: dict) -> None: + initial = build_planned_polyline(_line(), min_radius_m=RADIUS) + result = runs["results"]["follow"] + assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices] + assert result["curves"] == [curve.as_dict() for curve in initial.curves] + + +def _bend(radius: float) -> list[tuple[float, float]]: + """직선 100 m → 반지름 `radius` 원호 90° → 직선 100 m 를 2 m 간격 점으로.""" + points = [(float(x), 0.0) for x in np.arange(-100.0, 0.0, 2.0)] + steps = int(radius * math.pi / 2 / 2.0) + points += [ + (radius * math.sin(a), radius - radius * math.cos(a)) + for a in np.linspace(0.0, math.pi / 2, steps) + ] + points += [(radius, radius + float(y)) for y in np.arange(2.0, 102.0, 2.0)] + return points + + +def _window_gap(path, outline, curve) -> float: + """곡선 앞뒤 직선 가운데점 사이 경로 점이 노선에서 가장 먼 거리.""" + line = LineString(path) + drawn = LineString(outline.vertices) + apex = curve.apex + nodes = [(node.x, node.y) for node in outline.nodes] + at = nodes.index(min(nodes, key=lambda n: math.dist(n, apex))) + ends = [ + line.project(Point((apex[0] + n[0]) / 2, (apex[1] + n[1]) / 2)) + for n in (nodes[at - 1], nodes[at + 1]) + ] + return max( + drawn.distance(Point(p)) for p in path if min(ends) <= line.project(Point(p)) <= max(ends) + ) + + +def test_widen_largest_radius_within_gap() -> None: + path = _bend(80.0) + fitted = build_planned_polyline(path, min_radius_m=RADIUS) + wide = widen(fitted, path, RADIUS) + assert len(wide.curves) == 1 + curve = wide.curves[0] + assert curve.radius_m >= fitted.curves[0].radius_m + assert curve.radius_m == math.floor(curve.radius_m) # 1 m 내림 + assert curve.radius_m > 40.0 + assert _window_gap(path, wide, curve) <= ROUTE_RECOMMEND_MAX_GAP_M + 1e-6 + # 1 m 더 키우면 허용 이탈을 넘음(가장 큰 값) — 셈 자(shapely)로 따로 잼 + points, flags, radii = _flat(wide) + seat = flags.index(True) + radii[seat] = curve.radius_m + 1.0 + bigger = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii + ) + assert _window_gap(path, bigger, bigger.curves[0]) > ROUTE_RECOMMEND_MAX_GAP_M + + +def test_widen_survives_confirm_rebuild() -> None: + """화면이 펴서 보내는 꼴(교각점 · 곡선 켬 · R)로 [확인]이 다시 짜도 같은 R.""" + path = _bend(60.0) + [(60.0 + float(x), 160.0) for x in np.arange(2.0, 120.0, 2.0)] + wide = widen(build_planned_polyline(path, min_radius_m=RADIUS), path, RADIUS) + points, flags, radii = _flat(wide) + again = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii + ) + assert [c.radius_m for c in again.curves] == [c.radius_m for c in wide.curves] + assert again.vertices == wide.vertices + + +def test_widen_keeps_fitted_when_gap_already_over() -> None: + """허용 이탈 0 이면 키울 R 이 없어 맞춘 R 그대로.""" + path = _bend(80.0) + fitted = build_planned_polyline(path, min_radius_m=RADIUS) + same = widen(fitted, path, RADIUS, max_gap_m=0.0) + assert [round(c.radius_m, 2) for c in same.curves] == [ + round(c.radius_m, 2) for c in fitted.curves + ] diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py index 17c980d2a..e3c7ecce2 100644 --- a/resources/tester/test_244_8_b08_milestone_no_scroll.py +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -20,7 +20,10 @@ def _read(rel): def _node(script): - src = IMPORTS.sub("", _read("A00_Common/b_svg_zoom_pan.ts")) + # 휠 방향 읽기 층(332) 을 앞에 붙임 — Node 엔 저장소가 없어 기본(당기면 확대) + src = _read("A00_Common/input_prefs.ts") + IMPORTS.sub( + "", _read("A00_Common/b_svg_zoom_pan.ts") + ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + "\n" + script], diff --git a/resources/tester/test_input_prefs.py b/resources/tester/test_input_prefs.py new file mode 100644 index 000000000..c9c2cd0b7 --- /dev/null +++ b/resources/tester/test_input_prefs.py @@ -0,0 +1,113 @@ +"""PLAN 332-1 — 휠 확대 방향 한 설정(`A00_Common/input_prefs.ts`) · 모든 휠 화면이 그 판정을 거침. + +· 기본 = 당김(휠을 몸 쪽으로 = 확대) · 밀기 저장 뒤 뒤집힘 · 깨짐 · 모르는 값 · 막힌 저장소 · Node = 기본 +· 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대 +· 키 = 등록표 `input-prefs`(pref · global) 와 같은 글자 +짝: `helper_input_prefs.cjs`(실제 TS 를 Node 로 돌림). +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_input_prefs.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +PULL = {"pull": True, "push": False, "mouseCtrl": True, "bridgePull": True} +PINCH = {"padSpread": True, "padPinch": False, "fingerSpread": True} + + +def _pick(case: dict, keys: dict) -> dict: + return {k: case[k] for k in keys} + + +def test_Node_기본_당김(r): + n = r["node"] + assert n["default"] == {"wheelZoom": "pull", "cadAliases": {}} + assert n["prefs"] == n["default"] + assert _pick(n, PULL) == PULL and _pick(n, PINCH) == PINCH + + +def test_밀기_저장_뒤_뒤집힘_핀치는_그대로(r): + p = r["push"] + assert p["prefs"]["wheelZoom"] == "push" + assert _pick(p, PULL) == {"pull": False, "push": True, "mouseCtrl": False, "bridgePull": False} + assert _pick(p, PINCH) == PINCH # 벌리면 확대는 설정과 상관없음 + # 별칭 = 대문자 · 글자 아닌 것 · 빈 목록 버림 + assert p["prefs"]["cadAliases"] == {"LINE": ["LL"]} + + +@pytest.mark.parametrize("case", ["broken", "unknown", "empty", "blocked"]) +def test_깨짐_모르는_값_없음_막힘_기본(r, case): + c = r[case] + assert c["prefs"] == {"wheelZoom": "pull", "cadAliases": {}} + assert _pick(c, PULL) == PULL and _pick(c, PINCH) == PINCH + + +def test_등록표_키(r): + assert r["node"]["key"] == r["registryKey"] == "aislo:pref:input-prefs" + assert r["registryEntry"] == {"bucket": "pref", "scope": "global"} + + +def test_읽기_층은_의존_없음_불러올_때_window_안_만짐(): + src = _read("A00_Common/input_prefs.ts") + assert not re.search(r"^import ", src, re.M) + assert "window." not in src + + +# 휠 줌 자리 — 직접 박은 방향(`deltaY < 0 ?` · `deltaY > 0 ?` · `-ev.deltaY`)이 없고 공용 판정을 부름 +WHEEL_SITES = { + "A00_Common/touch_bridge.ts": "wheelEventIn(e, isPinchWheel(e))", + "A00_Common/b_svg_zoom_pan.ts": "wheelEventIn(event)", + "B04_PreProcess/B04_PreProcess_UI_Camera.ts": "wheelEventIn(event)", + "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts": "wheelZoomsIn(ev)", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts": "wheelZoomsIn(e)", + "ui_template/ui_template_loupe.ts": "wheelIn(deltaY)", + "A00_Common/spreadsheet/spreadsheet_grid.ts": "wheelZoomsIn(ev)", + # wheelZoomsIn 을 거쳐 따라오는 셋 + "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts": "wheelZoomsIn(event)", + "B05_Profile/B05_Profile_UI_Drainage_Interact.ts": "wheelZoomsIn(event)", + "B05_Profile/B05_Profile_UI_RouteEdit_Input.ts": "wheelZoomsIn(event)", +} + + +@pytest.mark.parametrize("rel", sorted(WHEEL_SITES)) +def test_휠_자리_공용_판정(rel): + src = _read(rel) + assert WHEEL_SITES[rel] in src + assert not re.search(r"deltaY [<>] 0 \?", src), rel + assert "-ev.deltaY" not in src and "Math.sign(-deltaY)" not in src + + +def test_700줄_안(): + for rel in WHEEL_SITES: + assert _read(rel).count("\n") <= 700, rel diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py new file mode 100644 index 000000000..526bfd4cb --- /dev/null +++ b/resources/tester/test_m02_hopyo_basis.py @@ -0,0 +1,108 @@ +"""일위대가 실무자료 단가산출 근거 맞춤(PLAN 330 · `M02_Hopyo_Basis` · 맞춤 후보 `근거`). + +블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] · +후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · +잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Match as hm +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + SABANG, + URL, + _post, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) + + +@pytest.fixture(scope="module") +def north() -> dict: + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +def _line(book: dict, no: int) -> dict: + """단산 no 를 가리키는 첫 줄.""" + return next(x for h in book["호표"] for x in h["줄"] if x["참조"] == {"종류": "단산", "번": no}) + + +def test_블록_수_와_근거(north): + assert len(north["단산"]) == 95 + assert [b["번"] for b in north["단산"]] == list(range(1, 96)) + sabang = hx.read_book(SABANG.name, SABANG.read_bytes()) + assert len(sabang["단산"]) == 67 + assert _line(north, 93)["근거"] == { + "출처": [["건설품셈", "3-2-1"]], + "직종": ["특별인부"], + "행": [4229, 4244], + } + assert _line(north, 21)["근거"]["직종"] == ["콘크리트공", "보통인부"] # 「보 통 인부」 빈칸 뺌 + assert _line(north, 64)["근거"]["출처"][0] == ["건설품셈", "4-1-1"] + lines = [x for h in north["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert all("근거" in x for x in lines) # 268 줄 모두 블록 있음 + + +def test_출처_글(): + assert hb.sources("[건설공사표준품셈 3-2-1 인력굴착]") == [["건설품셈", "3-2-1"]] + assert hb.sources("건설표줌품셈(3-1-3) 자갈") == [["건설품셈", "3-1-3"]] + assert hb.sources("[2025년 산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] + assert hb.sources("흙운반 [임도표준품셈 4-2]") == [["산림품셈", "4-2"]] + assert hb.sources("[건설표준품셈 준용]") == [] + + +def _top(book: dict, no: int) -> list[dict]: + got = hm.match_line(_line(book, no), [], hm.units_of(book)) + assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음 + return got["후보"] + + +def test_근거_후보(north): + before = master_hash() + dig = _top(north, 93) + assert dig[0]["로직"] == "GC000083" + assert dig[0]["근거"] == { + "조항": ["건설품셈 공통 3-2-1"], + "직종": ["특별인부"], + "단위": True, + "점수": 1.75, + "까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음", + } + assert _top(north, 21)[0]["로직"] == "GC000198" + assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] + assert _top(north, 49)[0]["로직"] == "GC000085" + soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리")) + names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]] + assert not any("철골재" in n for n in names) + assert master_hash() == before + + +def test_근거_없는_줄_그대로(north): + bare = [ + x + for h in north["호표"] + for x in h["줄"] + if x["갈래"] == "단가산출" and not x["근거"]["출처"] + ] + assert bare + for line in bare: + plain = {k: v for k, v in line.items() if k != "근거"} + assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {}) + + +def test_길_응답_근거_칸(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(f"{URL.format('personal')}/HP-000001/3") + lines = {x["이름"]: x for x in res.json()["호표"]["줄"]} + dig = lines["구조물터파기(인력100%)"] + assert dig["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert dig["맞춤"]["후보"][0]["근거"]["까닭"].startswith("조항 같음") + assert "근거" not in lines["임도표지석(받침,글새김포함)"] # 자재 줄 + assert master_hash() == before diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py new file mode 100644 index 000000000..16ba2b303 --- /dev/null +++ b/resources/tester/test_m02_hopyo_grid.py @@ -0,0 +1,120 @@ +"""일위대가 실무자료 엑셀꼴 격자(PLAN 329-1 · `M02_Hopyo_Grid` · `GET hopyo-grid/{키}`). + +북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · +제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + URL, + _post, + _xlsx, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) +GRID = "/api/m02/layers/{}/hopyo-grid/{}" + + +@pytest.fixture(scope="module") +def north() -> dict: + data = NORTH.read_bytes() + return hg.read(NORTH.name, data, hx.read_book(NORTH.name, data)) + + +def _sheet(grid: dict, name: str) -> dict: + return next(s for s in grid["책"]["시트"] if s["이름"] == name) + + +def test_격자_모양(north): + book = north["책"] + assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1") + assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"] + sheet = _sheet(north, "일위대가") + assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"] + assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2) + assert sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B20"]["값"] == "제3호표" + assert sheet["칸"]["H5"]["값"] == 1040052 + styles = book["서식"] + assert styles[0] == {} and any(s.get("형식") == "#,##0" for s in styles) + assert any(s.get("테두리") for s in styles) + assert _sheet(north, "단가산출")["칸"]["B4"]["값"] == "1.유용토운반(무대)[㎥]" + + +def test_호표_단산_행_범위(north): + rows = [h["행"] for h in north["호표"]] + assert len(rows) == 68 + assert all(a[1] < b[0] for a, b in zip(sorted(rows), sorted(rows)[1:], strict=False)) + assert next(h for h in north["호표"] if h["번"] == 3) == { + "번": 3, + "이름": "임도표지석", + "행": [20, 24], + } + assert len(north["단산"]) == 95 + assert next(b for b in north["단산"] if b["번"] == 93)["행"] == [4229, 4244] + + +def test_줄_행(north): + book = hx.read_book(NORTH.name, NORTH.read_bytes()) + three = next(h for h in book["호표"] if h["번"] == 3) + assert three["행"] == [20, 24] and [x["행"] for x in three["줄"]] == [21, 22, 23, 24] + + +def test_올리기_격자_200_404_지우기(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(GRID.format("personal", "HP-000001")) + assert res.status_code == 200, res.text + got = res.json() + assert got["책"]["열"] == "HP-000001" and len(got["호표"]) == 68 + folder = layers.personal_dir(7, 42) + path = folder / hs.DIRNAME / hs.GRID / "HP-000001.json" + assert path.is_file() + assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000001"] + path.unlink() # 옛 파일(324) = 격자 없음 + old = client.get(GRID.format("personal", "HP-000001")) + assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임" + assert _post(client).status_code == 200 + assert client.get(GRID.format("personal", "HP-000002")).status_code == 200 + assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200 + assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists() + assert client.get(GRID.format("personal", "HP-000002")).status_code == 404 + assert client.get(GRID.format("personal", "../x")).status_code == 404 + assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘 + assert master_hash() == before + + +@pytest.mark.parametrize( + ("role", "layer", "owner", "want"), + [ + ({"role": "USER"}, "personal", None, 200), + ({"role": "USER"}, "personal", "7/43", 403), + ({"role": "USER"}, "company", None, 200), + ({"role": "USER"}, "system", None, 200), + ], +) +def test_역할_읽기(client, role, layer, owner, want): + client.who.update({"role": "SYSTEM_ADMIN"}) + assert _post(client, layer if want == 200 else "personal").status_code == 200 + client.who.update(role) + params = {"owner": owner} if owner else {} + assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want + + +def test_xlsx_값만(): + data = _xlsx() + grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data)) + sheet = grid["책"]["시트"][0] + assert sheet["이름"] == "일위대가" and sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} + assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 + assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 0f1224240..69f8cd397 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -105,7 +105,7 @@ def test_이름_같음_후보_호표_퍼센트(client): assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "후보" and len(dig["후보"]) == 5 - assert dig["후보"][0]["이름"].startswith("구조물터파기") + assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위 ref = _hopyo(client, 45)["고화매트재료비"] assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] pct = _hopyo(client, 46)["잡재료비"] diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 46345114b..c844f2d7d 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -3,6 +3,8 @@ - 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · 본 화면 글 도우미 +- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상 +- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 - 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 """ @@ -15,7 +17,7 @@ from pathlib import Path HERE = os.path.dirname(os.path.abspath(__file__)) M02 = Path(HERE).parents[1] / "M02_MasterTemplete" -NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View") +NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet") def _text(name: str) -> str: @@ -68,7 +70,15 @@ def test_고르기_창_실무_호표_줄과_고름(): "name": "임도표지석 910×600×150", "sub": "북부청.xls · 제3호표 · 마스터", "unit": "개소 · 맞춤 2/4", - } + }, + { + "kind": "hopyo", + "key": "system||HP-000001|엑셀", + "tag": "실무 호표", + "name": "엑셀로 보기 · 여러 호표", + "sub": "북부청.xls · 마스터", + "unit": "호표 1", + }, ] build = got["calls"][1] assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 @@ -145,3 +155,57 @@ def test_로직_추가_한_곳과_파일_규칙(): css = _text("M02_MasterTemplete_Hopyo.css") assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") + + +def test_엑셀_보기_창_행과_호표(): + got = _run() + assert got["calls"][2] == { + "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", + "method": "GET", + "body": None, + } + sheet = got["sheet"] + assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 + assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) + assert sheet["last"] == ["AA", "A"] + assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + + +def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): + lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view + assert "호표: no" in view # 호표 화면 = 그 호표 고른 채 + pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") + assert lazy in pick and "lib.pick(got.참조" in pick + static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' + for text in (side, view, pick): + assert not re.search(static, text, re.M) # 정적 import 없음(타입만) + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet + assert not re.search( + r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M + ) + assert "readOnly: true" in sheet and "wide: true" in sheet + + +def test_고르기_창_근거_꼬리표와_근거_맞음(): + basis = _run()["basis"] + assert basis["ok"] == [True, False, False, False] # 조항 · 직종 · 단위 모두 + assert basis["tag"] == [ + "조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음", + "조항 건설 공통 3-2-1 · 단위 같음", + "", + ] + assert basis["source"] == [ + "출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행", + "단가산출 7행", + "", + ] + # 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음 + assert basis["pick"] == ["GC000083", "GC000083", None, None] + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view + assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음 diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 16479963a..68a0cfee4 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -14,6 +14,7 @@ import "./ui_template_loupe.css"; import { keyLetter } from "../A00_Common/key_shortcut"; +import { wheelIn } from "../A00_Common/input_prefs"; /** 원 지름(px) */ export const LOUPE_DIAMETER = 360; @@ -86,9 +87,9 @@ export function isCadTarget(target: unknown): boolean { return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); } -/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ +/** 휠 한 번 뒤 배율 — ☰ [설정] 방향(332 · 기본 당김 = deltaY > 0 = 크게) · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { - const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; + const step = deltaY ? (wheelIn(deltaY) ? 1 : -1) * LOUPE_ZOOM_STEP : 0; return Math.max(LOUPE_ZOOM_MIN, Math.min(LOUPE_ZOOM_MAX, zoom + step)); }