Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLElement>(".b05-profile__masshaul-overlay");
|
||||
const massHandle = profile.root.querySelector<HTMLElement>(".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<HTMLElement>(".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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
}
|
||||
|
||||
/** `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<string, string[]> => {
|
||||
const out: Record<string, string[]> = {};
|
||||
if (!raw || typeof raw !== "object") return out;
|
||||
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
|
||||
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<InputPrefs> | 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<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
|
||||
e.ctrlKey && Math.abs(e.deltaY) < 50;
|
||||
|
||||
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
|
||||
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
|
||||
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
|
||||
@@ -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<string | null>;
|
||||
/** 범위 고름 · 보이게 굴림(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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
|
||||
|
||||
@@ -211,6 +211,8 @@ export interface StationTickOptions {
|
||||
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
|
||||
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
|
||||
onLabel?: (box: StationLabelBox) => void;
|
||||
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
|
||||
chainages?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/** 그려진 측점 라벨 글자 상자 — 가운데 (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;
|
||||
|
||||
@@ -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<string, number> | null };
|
||||
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
||||
@@ -220,6 +222,8 @@ export interface CrossPreviewRequest {
|
||||
station_interval_m: number;
|
||||
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
||||
cross_half_width_m?: number;
|
||||
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
|
||||
station_snap?: boolean;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
|
||||
|
||||
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
|
||||
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을
|
||||
바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
|
||||
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을
|
||||
바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙
|
||||
(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
|
||||
|
||||
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"` 으로 남아 측점 셈이 읽음."""
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface DrainagePanel {
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
|
||||
* 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */
|
||||
setIntervalSpans: (spans: ReadonlyArray<RouteSpanBand>) => void;
|
||||
/** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */
|
||||
rehome: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [확인] 때 저장">측점 스냅</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
@@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
'[data-act="contour-minor"]',
|
||||
)!;
|
||||
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
|
||||
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
|
||||
|
||||
return {
|
||||
overlay,
|
||||
@@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
measureText,
|
||||
measureButton,
|
||||
minorContourButton,
|
||||
stationSnapButton,
|
||||
terrainSelect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number> | null;
|
||||
apex?: ReadonlyArray<number> | null;
|
||||
end?: ReadonlyArray<number> | null;
|
||||
radius_m?: number | null;
|
||||
}
|
||||
|
||||
/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */
|
||||
export function stationsAlong(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
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, 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<Vertex>,
|
||||
curves: ReadonlyArray<CurveEnds>,
|
||||
): 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<number>,
|
||||
spans: ReadonlyArray<readonly [number, number]>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -321,13 +321,15 @@ export function stationAtScreen(
|
||||
py: number,
|
||||
maxPx: number,
|
||||
labels: ReadonlyArray<StationLabelBox> = [],
|
||||
/** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */
|
||||
chainages: ReadonlyArray<number> | 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<number> | null,
|
||||
chainages: ReadonlyArray<number> | 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];
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface RouteEditPointerParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
status: HTMLElement;
|
||||
stationIntervalM: number;
|
||||
/** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
stations: () => ReadonlyArray<number> | 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);
|
||||
|
||||
@@ -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 = "셈하지 못했습니다.";
|
||||
|
||||
@@ -29,7 +29,7 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
|
||||
follow: {
|
||||
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
|
||||
how: [
|
||||
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
|
||||
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
|
||||
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
|
||||
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
|
||||
],
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface RouteEditScene {
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
stations: ReadonlyArray<number> | null;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<MeasureMark>;
|
||||
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 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);
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SlopeDrawArgs {
|
||||
uprightRad: number;
|
||||
/** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */
|
||||
dragging: boolean;
|
||||
/** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
chainages?: ReadonlyArray<number> | null;
|
||||
}
|
||||
|
||||
export interface SlopeLabels {
|
||||
@@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
/** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */
|
||||
let cachedLine: ReadonlyArray<Vertex> | null = null;
|
||||
let cachedInterval = 0;
|
||||
/** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */
|
||||
let cachedKey = "";
|
||||
let chainages: number[] = [];
|
||||
let heights: Array<number | null> = [];
|
||||
/** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */
|
||||
@@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
let asking: ReadonlyArray<Vertex> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function schedule(line: ReadonlyArray<Vertex>, intervalM: number): void {
|
||||
function schedule(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
intervalM: number,
|
||||
marks: ReadonlyArray<number> | 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; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다.
|
||||
}
|
||||
// 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 —
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
/** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김.
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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["점수"])
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []],
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HopyoFile[]> =>
|
||||
export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`));
|
||||
|
||||
/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */
|
||||
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
|
||||
call(at(scope, `/hopyo-grid/${enc(key)}`));
|
||||
|
||||
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}`));
|
||||
|
||||
|
||||
@@ -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<string, HopyoPickRow[]>();
|
||||
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),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Sheet, "칸" | "열">): 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<number>();
|
||||
const items = new Map<number, HTMLElement>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
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<void> {
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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));
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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] == [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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["단산"] == []
|
||||
@@ -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)["잡재료비"]
|
||||
|
||||
@@ -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 # 옛 파일 = 단추 없음
|
||||
|
||||
@@ -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<Element> | 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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user