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

This commit is contained in:
2026-10-15 12:44:39 +09:00
84 changed files with 4163 additions and 243 deletions
+2
View File
@@ -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": {
+4 -2
View File
@@ -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 },
);
+29
View File
@@ -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,
+106 -23
View File
@@ -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(),
});
}
}
+2 -1
View File
@@ -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;
}
+64
View File
@@ -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);
+16 -1
View File
@@ -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();
+3 -1
View File
@@ -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);
+4 -3
View File
@@ -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),
+3 -2
View File
@@ -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;
+4
View File
@@ -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;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
+20 -6
View File
@@ -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
+19 -6
View File
@@ -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
+33 -83
View File
@@ -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,
+87 -23
View File
@@ -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
+10 -1
View File
@@ -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
+74 -1
View File
@@ -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"` 으로 남아 측점 셈이 읽음."""
+2 -2
View File
@@ -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;
}
+21 -6
View File
@@ -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에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */
+5 -3
View File
@@ -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(
+36 -2
View File
@@ -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);
+18 -4
View File
@@ -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 },
);
+231
View File
@@ -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["점수"])
+22 -4
View File
@@ -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:
+211
View File
@@ -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 []],
}
+11 -8
View File
@@ -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)
+27 -3
View File
@@ -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)
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
+17 -2
View File
@@ -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 },
);
+2
View File
@@ -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")
+17 -15
View File
@@ -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 = {
+63
View File
@@ -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));
+44 -2
View File
@@ -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] == [
+3 -3
View File
@@ -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
+388
View File
@@ -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],
+113
View File
@@ -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
+108
View File
@@ -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
+120
View File
@@ -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["단산"] == []
+1 -1
View File
@@ -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)["잡재료비"]
+66 -2
View File
@@ -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 # 옛 파일 = 단추 없음
+3 -2
View File
@@ -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));
}