diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css new file mode 100644 index 000000000..4ac71bdcf --- /dev/null +++ b/A00_Common/app_settings.css @@ -0,0 +1,53 @@ +/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */ +.app-settings__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.app-settings__radios { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-16); +} + +.app-settings__radios > label { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + min-height: 32px; + cursor: pointer; +} + +.app-settings__table { + width: 100%; + border-collapse: collapse; +} + +.app-settings__table th, +.app-settings__table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.app-settings__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); +} + +.app-settings__table input[aria-invalid="true"] { + border-color: var(--color-danger); +} + +.app-settings__error { + color: var(--color-danger); +} + +.app-settings__error:empty { + display: none; +} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts new file mode 100644 index 000000000..d5ab19607 --- /dev/null +++ b/A00_Common/app_settings.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * app_settings.ts + * ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용). + * + * - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts` + * - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함 + * - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에) + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { askLeave } from "@ui/ui_template_leave_dialog"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { writeState } from "./b_page_state"; +import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs"; +import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad"; +import "./app_settings.css"; + +type LocaleKey = Parameters[0]; +type TabId = "mouse" | "cad" | "keys"; + +const TABS: [TabId, LocaleKey][] = [ + ["mouse", "App_Settings_Tab_Mouse"], + ["cad", "App_Settings_Tab_Cad"], + ["keys", "App_Settings_Tab_Keys"], +]; + +const WHEEL: [WheelZoom, LocaleKey][] = [ + ["pull", "App_Settings_Wheel_Pull"], + ["push", "App_Settings_Wheel_Push"], +]; + +const MOUSE_RULES: LocaleKey[] = [ + "App_Settings_Rule_Pan", + "App_Settings_Rule_Pick", + "App_Settings_Rule_Right", + "App_Settings_Rule_Pinch", +]; + +/** ③ 안내표 줄 — [자리, 키] */ +const KEY_ROWS: [LocaleKey, LocaleKey][] = [ + ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], + ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], + ["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"], + ["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"], + ["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"], + ["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"], + ["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"], +]; + +/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */ +const normalize = (p: InputPrefs): InputPrefs => ({ + wheelZoom: p.wheelZoom, + cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))), +}); +const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p)); + +function mouseTab(draft: InputPrefs): HTMLElement { + const group = el("div", { + className: "app-settings__radios", + attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") }, + }); + for (const [mode, key] of WHEEL) { + const input = el("input", { + attrs: { type: "radio", name: "app-settings-wheel", value: mode }, + }); + input.checked = draft.wheelZoom === mode; + input.addEventListener("change", () => (draft.wheelZoom = mode)); + group.append(el("label", { children: [input, L(key)] })); + } + return el("div", { + children: [ + el("h4", { text: L("App_Settings_Wheel") }), + group, + el("h4", { text: L("App_Settings_Rules") }), + el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }), + ], + }); +} + +function keysTab(): HTMLElement { + const rows = KEY_ROWS.map(([place, keys]) => + el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }), + ); + return el("table", { + className: "app-settings__table", + children: [el("tbody", { children: rows })], + }); +} + +/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */ +export function openAppSettings(): void { + const draft = normalize(inputPrefs()); + let saved = snapshot(draft); + const cad = buildCadAliasTab(draft.cadAliases); + const panels: Record = { + mouse: mouseTab(draft), + cad: cad.root, + keys: keysTab(), + }; + const tabs = el("div", { + className: "app-settings__tabs ui-subtabs", + attrs: { role: "tablist" }, + }); + const show = (id: TabId): void => { + tabs.replaceChildren( + ...TABS.map(([tab, key]) => { + const button = createButton({ + label: L(key), + variant: tab === id ? "filled" : "ghost", + size: "sm", + onClick: () => show(tab), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(tab === id)); + button.dataset.settingsTab = tab; + return button; + }), + ); + for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id; + }; + + /** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */ + const save = (): boolean => { + const errors = checkCadAliases(draft.cadAliases); + cad.mark(errors); + if (Object.keys(errors).length) { + show("cad"); + showToast(L("App_Settings_Cad_Invalid"), "error"); + return false; + } + if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft)); + saved = snapshot(draft); + showToast(L("App_Settings_Saved"), "success"); + return true; + }; + + const saveButton = createButton({ + label: L("Common_Btn_Save"), + variant: "filled", + size: "sm", + onClick: () => void save(), + }); + openModal({ + title: L("App_Settings"), + closeLabel: L("Common_Btn_Close"), + dialogClass: "app-settings", + actions: [saveButton], + beforeClose: async () => { + if (snapshot(draft) === saved) return true; + const choice = await askLeave(L("App_Settings_Leave")); + return choice === "discard" || (choice === "save" && save()); + }, + mount: (body) => { + show("mouse"); + body.append(tabs, ...Object.values(panels)); + }, + }); +} diff --git a/A00_Common/app_settings_cad.ts b/A00_Common/app_settings_cad.ts new file mode 100644 index 000000000..217623f03 --- /dev/null +++ b/A00_Common/app_settings_cad.ts @@ -0,0 +1,118 @@ +/* ============================================================================= + * app_settings_cad.ts + * ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사. + * + * - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌) + * - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3) + * - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지 + * ========================================================================== */ + +import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; + +export interface CadCommandRow { + id: string; + label: string; + aliases: string[]; +} + +export const CAD_COMMANDS = catalog as CadCommandRow[]; + +export type AliasError = "chars" | "dup" | "name"; + +const ERROR_TEXT: Record[0]> = { + chars: "App_Settings_Cad_ErrChars", + dup: "App_Settings_Cad_ErrDup", + name: "App_Settings_Cad_ErrName", +}; + +/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */ +export const parseAliases = (text: string): string[] => + text + .toUpperCase() + .split(/[\s,]+/) + .filter(Boolean); + +/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */ +export function checkCadAliases( + aliases: Record, + commands: CadCommandRow[] = CAD_COMMANDS, +): Record { + const ids = new Set(commands.map((c) => c.id)); + const owner = new Map(); + const errors: Record = {}; + for (const [id, list] of Object.entries(aliases)) { + for (const alias of list) { + const first = owner.get(alias); + const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias) + ? "chars" + : ids.has(alias) + ? "name" + : first !== undefined + ? "dup" + : null; + if (!error) owner.set(alias, id); + else { + errors[id] ??= error; + if (first !== undefined) errors[first] ??= "dup"; + } + } + } + return errors; +} + +/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */ +export function buildCadAliasTab(aliases: Record): { + root: HTMLElement; + mark: (errors: Record) => void; +} { + const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } }); + const inputs = new Map(); + const rows = CAD_COMMANDS.map((command) => { + const input = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` }, + }); + input.value = (aliases[command.id] ?? []).join(", "); + input.dataset.cadCommand = command.id; + input.addEventListener("input", () => { + const list = parseAliases(input.value); + if (list.length) aliases[command.id] = list; + else delete aliases[command.id]; + }); + inputs.set(command.id, input); + const cells = [command.id, command.label, command.aliases.join(", ")].map((text) => + el("td", { text }), + ); + return el("tr", { children: [...cells, el("td", { children: [input] })] }); + }); + const head = el("tr", { + children: ( + [ + "App_Settings_Cad_Command", + "App_Settings_Cad_Label", + "App_Settings_Cad_Default", + "App_Settings_Cad_Mine", + ] as const + ).map((key) => el("th", { text: L(key) })), + }); + const table = el("table", { + className: "app-settings__table", + children: [el("thead", { children: [head] }), el("tbody", { children: rows })], + }); + const mark = (errors: Record): void => { + for (const [id, input] of inputs) { + const error = errors[id]; + if (error) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); + input.title = error ? L(ERROR_TEXT[error]) : ""; + } + const kinds = [...new Set(Object.values(errors))]; + message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); + }; + const root = el("div", { + children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table], + }); + return { root, mark }; +} diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 13aec256f..0d95b131d 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -118,10 +118,11 @@ function buildBrand(): HTMLButtonElement { /* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠. * 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */ -let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null; +let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null; +/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */ export function setDrawerSteps(node: HTMLElement | null): void { - drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null; + drawerSteps = node ? { node, home: node.parentElement } : null; } const NAV_ITEMS: [keyof typeof ui_locales, string][] = [ @@ -166,15 +167,20 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { burger.setAttribute("aria-expanded", String(open)); if (!drawerSteps) return; if (open) steps.append(drawerSteps.node); - else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node); + else if (drawerSteps.home && steps.contains(drawerSteps.node)) + drawerSteps.home.append(drawerSteps.node); }; burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘 - // 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) + // 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) drawer.addEventListener("click", (e) => { const hit = (e.target as Element).closest("button"); - if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth")) + if ( + hit?.closest( + ".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings", + ) + ) setOpen(false); }); @@ -199,6 +205,14 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { onClick: onApp_Theme_Toggle_Click, }), ); + // ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴 + const settingsButton = createButton({ + label: L("App_Settings"), + variant: "pill", + onClick: () => void import("./app_settings").then((m) => m.openAppSettings()), + }); + settingsButton.classList.add("app-drawer__settings"); + prefs.append(settingsButton); const auth = document.createElement("div"); auth.className = "app-drawer__row app-drawer__auth"; void fillAuthActions(auth); diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b3745a035..2f14d7bab 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -103,6 +103,8 @@ export const STATE_REGISTRY = { "cross-columns": { bucket: "pref", scope: "global" }, /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, + /** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */ + "input-prefs": { bucket: "pref", scope: "global" }, /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ "b05-3d-projection": { bucket: "pref", scope: "global" }, "section-panel-height": { diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 9590e576c..ea40f76d9 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -14,6 +14,8 @@ * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { scale: number; @@ -213,8 +215,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const rect = svg.getBoundingClientRect(); const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx; const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx; - // 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`). - zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy); + // ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐. + zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy); }, { passive: false }, ); diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 605935355..7ffb67950 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,6 +55,35 @@ font-size: var(--btn-bar-font-size); } +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ +.b05-subpanel__popout { + flex: none; + padding: var(--btn-bar-padding); + font-size: var(--btn-bar-font-size); +} + +.b05-profile__masshaul-overlay > .b05-subpanel__popout { + position: absolute; + top: 4px; + left: var(--spacing-8); + z-index: 4; +} + +/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ +.b05-profile__masshaul-overlay.is-popout, +.b05-drainage.is-popout { + position: relative; + width: 100%; + max-width: none; + height: 100dvh; +} + +.b05-profile__masshaul-overlay.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-workflow-overlay__handle { + display: none; +} + /* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ .b05-route__panel .b06-adjust-dock, diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index ce69d30d5..304bdb07f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -1,6 +1,7 @@ /* ============================================================================= * A00_Common/design/design_profile_popout.ts * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). + * 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름. * * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 @@ -13,28 +14,39 @@ import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; -/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ -export function attachProfilePopout( - profile: DesignProfile, - slot: HTMLElement, - button: HTMLButtonElement, -): void { - const { root } = profile; +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window; + +/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */ +interface PopoutSpec { + piece: HTMLElement; + button: HTMLButtonElement; + name: string; + features: string; + docTitle: string; + /** 조각을 제자리로 돌림. */ + home: () => void; + /** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */ + onMove?: (popped: boolean) => void; +} + +/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */ +function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { + const { piece, button } = spec; let popup: Window | null = null; button.type = "button"; - button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.textContent = "창 분할"; - button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); const restore = (): void => { - if (!root.classList.contains("is-popout")) return; - root.classList.remove("is-popout"); + if (!piece.classList.contains("is-popout")) return; + piece.classList.remove("is-popout"); button.textContent = "창 분할"; - slot.append(root); + spec.home(); popup = null; - requestAnimationFrame(profile.redraw); + spec.onMove?.(false); + viewOfNode(piece).requestAnimationFrame(profile.redraw); }; const closePopup = (): void => { popup?.close(); @@ -49,28 +61,31 @@ export function attachProfilePopout( }; function open(): void { - const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720"); + const opener = viewOfNode(button); + const next = opener.open("", spec.name, spec.features); if (!next) { showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); return; } popup = next; + const source = opener.document; const doc = next.document; - doc.title = "종단 — Aislo"; - // 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. + doc.title = spec.docTitle; + // 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. doc.head.replaceChildren( - ...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) => + ...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) => doc.importNode(node, true), ), ); - Array.from(document.documentElement.attributes).forEach((attr) => + Array.from(source.documentElement.attributes).forEach((attr) => doc.documentElement.setAttribute(attr.name, attr.value), ); - doc.body.className = document.body.className; - doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; - root.classList.add("is-popout"); + doc.body.className = source.body.className; + doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`; + piece.classList.add("is-popout"); button.textContent = "창 결합"; - doc.body.replaceChildren(root); + doc.body.replaceChildren(piece); + spec.onMove?.(true); // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). FORWARD_TYPES.forEach((type) => @@ -93,7 +108,75 @@ export function attachProfilePopout( new (next as Window & typeof globalThis).ResizeObserver(() => { next.cancelAnimationFrame(pending); pending = next.requestAnimationFrame(profile.redraw); - }).observe(root); + }).observe(piece); next.addEventListener("pagehide", restore); } } + +/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ +export function attachProfilePopout( + profile: DesignProfile, + slot: HTMLElement, + button: HTMLButtonElement, +): void { + button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; + button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; + attachPopout(profile, { + piece: profile.root, + button, + name: "aislo-profile-popout", + features: "popup,width=1400,height=720", + docTitle: "종단 — Aislo", + home: () => slot.append(profile.root), + }); +} + +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ +export function attachSubPanelPopouts(profile: DesignProfile): void { + const subButton = (title: string): HTMLButtonElement => { + const button = document.createElement("button"); + button.className = "ui-btn ui-btn--ghost b05-subpanel__popout"; + button.title = title; + return button; + }; + + const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); + const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); + if (massOverlay && massHandle) { + const button = subButton("유토곡선 창 분할"); + massOverlay.append(button); + attachPopout(profile, { + piece: massOverlay, + button, + name: "aislo-masshaul-popout", + features: "popup,width=1400,height=420", + docTitle: "유토곡선 — Aislo", + // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). + home: () => massHandle.before(massOverlay), + onMove: (popped) => { + // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). + massHandle.hidden = popped; + massOverlay.style.height = ""; + }, + }); + } + + const drainage = profile.side; + const header = drainage?.root.querySelector(".b05-drainage__header"); + if (drainage && header) { + const button = subButton("배수유역 창 분할"); + header.prepend(button); + const content = drainage.root.parentElement; + attachPopout(profile, { + piece: drainage.root, + button, + name: "aislo-drainage-popout", + features: "popup,width=1000,height=720", + docTitle: "배수유역 — Aislo", + // 제자리 = 종단 content 끝(종단 본문 오른쪽). + home: () => content?.append(drainage.root), + // 지도 크기 관찰을 놓인 창 것으로(325-1). + onMove: () => drainage.rehome(), + }); + } +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 9296eb899..3374528c1 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -24,7 +24,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; -import { attachProfilePopout } from "./design_profile_popout"; +import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -179,6 +179,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { }); shared.profileSlot.append(profile.root); attachProfilePopout(profile, shared.profileSlot, popoutButton); + attachSubPanelPopouts(profile); shared.profile = profile; return profile; } diff --git a/A00_Common/input_prefs.ts b/A00_Common/input_prefs.ts new file mode 100644 index 000000000..a13c87f7c --- /dev/null +++ b/A00_Common/input_prefs.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * input_prefs.ts + * 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀. + * + * - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름) + * - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import) + * - 휠 확대 방향 하나로 모든 화면 일체 — 기본 밂 = 휠을 앞으로(deltaY < 0) 굴리면 확대(오토캐드) · 당김 = 몸 쪽으로 + * - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0) + * ========================================================================== */ + +export type WheelZoom = "pull" | "push"; + +export interface InputPrefs { + /** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */ + wheelZoom: WheelZoom; + /** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */ + cadAliases: Record; +} + +/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */ +export const INPUT_PREFS_KEY = "aislo:pref:input-prefs"; + +export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "push", cadAliases: {} }; + +const cleanAliases = (raw: unknown): Record => { + const out: Record = {}; + if (!raw || typeof raw !== "object") return out; + for (const [id, list] of Object.entries(raw as Record)) { + if (!Array.isArray(list)) continue; + const aliases = list + .filter((a): a is string => typeof a === "string") + .map((a) => a.toUpperCase()); + if (aliases.length) out[id.toUpperCase()] = aliases; + } + return out; +}; + +/** 지금 설정 — 부를 때마다 저장소에서 읽음 */ +export function inputPrefs(): InputPrefs { + try { + const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY); + if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + const value = JSON.parse(raw) as Partial | null; + return { + wheelZoom: value?.wheelZoom === "pull" ? "pull" : "push", + cadAliases: cleanAliases(value?.cadAliases), + }; + } catch { + return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + } +} + +/** 휠 한 번이 확대인가 — 설정 방향을 따름 */ +export const wheelIn = (deltaY: number): boolean => + inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0; + +/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄 + * ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */ +export const isTrackpadPinch = (e: Pick): boolean => + e.ctrlKey && Math.abs(e.deltaY) < 50; + +/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */ +export const wheelEventIn = (e: Pick, pinch = false): boolean => + pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY); diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts new file mode 100644 index 000000000..432177a8e --- /dev/null +++ b/A00_Common/m_settings_nav.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * m_settings_nav.ts + * 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는 + * 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`). + * ========================================================================== */ + +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "./app_shell"; +import { currentRoute, navigateTo, takeLeaveGuard } from "./router"; + +interface SettingUser { + role?: string; + is_master?: boolean; + company_id?: number | null; +} + +/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */ +export interface SettingLink { + key: string; + title: string; + desc: string; + route: RoutePath; + query?: Record; +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +const ICONS: Record = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" }; + +/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] { + // 시스템 관리자는 고른 회사를 해시에 실음(29-1) + const company: Record = + user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; + const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({ + key: open, + title, + desc, + route: ROUTES.M02_MASTER_TEMPLATE, + query: { open, root, ...company }, + }); + // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 + const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); + if (user.role === "SYSTEM_ADMIN") { + const desc = L("B01_Dashboard_PermMasterWrite"); + return [ + { + key: "m01", + title: L("B01_Dashboard_MasterElements"), + desc: L("B01_Dashboard_PermSourceRead"), + route: ROUTES.M01_MASTER_DATA, + }, + m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), + m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), + m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), + ]; + } + // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) + const companyAdmin = user.role === "ADMIN" || user.is_master; + const root = companyAdmin ? "company" : "personal"; + const desc = [ + L("B01_Dashboard_PermMasterRead"), + L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), + L("B01_Dashboard_PermPersonalWrite"), + ].join(" · "); + return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)]; +} + +/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */ +export function mountSettingsDrawer( + user: SettingUser, + activeKey: string, + companyId: number | string | null, +): void { + const links = settingLinks(user, companyId); + const bar = createStepBar( + links.map((link) => link.title), + links.findIndex((link) => link.key === activeKey), + { + orientation: "vertical", + icons: links.map((link) => ICONS[link.key] ?? ""), + routes: links.map((link) => link.route), + homeButton: true, + onStepClick: (index) => void go(links[index]), + }, + ); + setDrawerSteps(bar); +} + +async function go(link: SettingLink): Promise { + const guard = link.route === currentRoute() ? takeLeaveGuard() : null; + if (guard && !(await guard())) return; + navigateTo(link.route, link.query); +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 27ac5da6a..6d7fefd78 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -28,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links"; import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; -import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; +import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection"; import { createStatusBar } from "./spreadsheet_statusbar"; import { mountTabs } from "./spreadsheet_tabs"; import { mountToolbar } from "./spreadsheet_toolbar"; @@ -90,6 +90,9 @@ export interface SpreadsheetHandle { /** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) · * `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */ pasteTable(html: string, check?: PasteCheck): Promise; + /** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) · + * 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */ + select(범위: string | string[], 시트?: string): boolean; destroy(): void; } @@ -350,6 +353,18 @@ export function createSpreadsheet( return 이름; }, pasteTable: (html, check) => pasteTableAt(ctx, html, check), + select(range, sheetKey) { + const target = sheetKey + ? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey) + : ctx.sheet(); + const next = target && selectRanges(target, typeof range === "string" ? [range] : range); + if (!target || !next) return false; + showSheet(target.id); + if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘 + select(next, true); + grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index da403b2d4..9e0b97db7 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -13,6 +13,7 @@ import "./spreadsheet_grid.css"; import { colIndex, toA1 } from "./spreadsheet_address"; +import { wheelZoomsIn } from "../touch_bridge"; import { buildColGeometry, buildOutlineRuns, @@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { if (!ev.ctrlKey) return; ev.preventDefault(); const before = zoom; - const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); + const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332) + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step))); if (next === before) return; zoom = next; colGeo = scaledGeometry(baseColGeo, zoom); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 46c0c3e8c..750318511 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; } +/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */ +export function selectRanges(sheet: Sheet, texts: string[]): Selection | null { + const list = merges(sheet); + const 범위: CellRange[] = []; + for (const t of texts) { + const g = parseRange(t); + if (!g) return null; + 범위.push(expand(g, list)); + } + if (!범위.length) return null; + const at = { r: 범위[0].r0, c: 범위[0].c0 }; + return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } }; +} + /** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { const r0 = Math.min(a, b); diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts index 6c83aa700..6fc8a810d 100644 --- a/A00_Common/touch_bridge.ts +++ b/A00_Common/touch_bridge.ts @@ -11,6 +11,8 @@ * 마우스 · 펜 이벤트는 건드리지 않음. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + const LONG_MS = 500; const SLOP_PX = 10; const DOUBLE_MS = 300; @@ -31,9 +33,8 @@ let contactPx = 0; /** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */ export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px); -/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */ -export const wheelZoomsIn = (e: WheelEvent): boolean => - isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0; +/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */ +export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e)); const ownsTouch = (t: EventTarget | null): boolean => t instanceof Element && !!t.closest("[data-touch-own]"); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 4278b4777..82515ecfc 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -9,7 +9,10 @@ import { } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; -import { navigateTo } from "../A00_Common/router"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "../A00_Common/app_shell"; +import { settingLinks } from "../A00_Common/m_settings_nav"; +import { currentRoute, navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, fetchAllUsers, @@ -162,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement { return card; } -/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__setting-grid"; - // 시스템 관리자는 고른 회사를 해시에 실음(29-1) - const company: Record = - user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; - const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => - settingCard(title, desc, () => - navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }), - ); - // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 - const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); - if (user.role === "SYSTEM_ADMIN") { - const desc = L("B01_Dashboard_PermMasterWrite"); - wrap.append( - settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => - navigateTo(ROUTES.M01_MASTER_DATA), - ), - m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), - m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), - m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), - ); - return wrap; - } - // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) - const companyAdmin = user.role === "ADMIN" || user.is_master; - const root = companyAdmin ? "company" : "personal"; - const desc = [ - L("B01_Dashboard_PermMasterRead"), - L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), - L("B01_Dashboard_PermPersonalWrite"), - ].join(" · "); - wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)); + wrap.append( + ...settingLinks(user, companyId).map((link) => + settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)), + ), + ); return wrap; } @@ -231,6 +209,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement { return card; } +/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */ +const SECTION_ICONS: [Parameters[0], string][] = [ + ["B01_Dashboard_Resources", "📊"], + ["B01_Dashboard_SystemSettings", "⚙️"], + ["B01_Dashboard_Projects", "🗂️"], + ["B01_Temp_Section", "📦"], + ["B01_Dashboard_Users", "👥"], + ["B01_Dashboard_Members", "👥"], + ["B01_Dashboard_JoinRequests", "📨"], + ["B01_Dashboard_Companies", "🏢"], + ["B01_Dashboard_Company", "🏢"], + ["B01_Dashboard_Profile", "👤"], + ["B01_Account_Section_Security", "🔒"], + ["B01_Dashboard_AuditLogs", "📜"], +]; + +/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */ +function mountSectionShortcuts(grid: HTMLElement): void { + // 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음 + if (currentRoute() !== ROUTES.B01_ACCOUNT) return; + const sections = [...grid.children] as HTMLElement[]; + const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? ""); + const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? ""); + const bar = createStepBar(titles, -1, { + orientation: "vertical", + icons, + routes: titles, + onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }), + }); + setDrawerSteps(bar); +} + function buildPage(state: DashboardState): HTMLElement { // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); @@ -311,6 +321,7 @@ function buildPage(state: DashboardState): HTMLElement { grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); } page.append(grid); + mountSectionShortcuts(grid); page.append(buildKnowledgeChat()); const layout = createGeneralLayout({ pageClass: "b01-dashboard", diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 703c27d4e..90791b126 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -350,6 +350,18 @@ async def run_auto_design_chain( logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") + # 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도 + # 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다. + try: + from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task + from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults + + _schedule_background_task( + precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}" + ) + except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다 + logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id) + return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index d5adca1d9..8bf42cc44 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; +import { wheelEventIn } from "../A00_Common/input_prefs"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; @@ -246,11 +247,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. - * 휠을 위로 올리면 멀어진다(사용자 지시). */ + * 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */ function onWheel(event: WheelEvent): void { if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); - zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP); + zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP); } /** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */ diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 73543179e..e46176d19 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -211,6 +211,8 @@ export interface StationTickOptions { /** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고, * 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */ onLabel?: (box: StationLabelBox) => void; + /** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */ + chainages?: ReadonlyArray; } /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ @@ -253,7 +255,10 @@ export function drawStationTicks( // 이미 그린 라벨과 겹치는 자리는 비켜 놓는다. const drawn: Array<{ x: number; y: number; half: number }> = []; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += interval) { + const marks: number[] = []; + if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage); + for (const chainage of marks) { const stationNo = Math.round(chainage / interval); if (stationNo % step !== 0) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 3285d710d..97ac66fb1 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -100,6 +100,8 @@ export interface RoutePlanResponse { violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; + /** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */ + station_snap?: boolean; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ @@ -220,6 +222,8 @@ export interface CrossPreviewRequest { station_interval_m: number; /** 횡단 반폭(m) — 없으면 서버 기본값. */ cross_half_width_m?: number; + /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ + station_snap?: boolean; } /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ba7278c84..13a1e0fd5 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -3,8 +3,9 @@ 예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중. -계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 -받은 노드로 기존 `POST /route/replan`([확인])을 부른다. +계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로 +`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로 +기존 `POST /route/replan`([확인])을 부른다. 갈래(`mode`): · follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선. @@ -182,6 +183,20 @@ def write_initial_choice( return choice +def resolve_inputs( + max_grade: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, +) -> tuple[float, float, float]: + """(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이.""" + stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) + grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) + # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) + goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + return stream_offset, grade_smooth, goal_grade + + def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" if mode != "weighted": @@ -240,10 +255,9 @@ def generate_initial_route( if len(expected) < 2: raise ValueError("예상노선 정점이 2개 미만입니다.") used = resolve_weights(mode, weights) - stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) - grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) - goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + stream_offset, grade_smooth, goal_grade = resolve_inputs( + max_grade, stream_offset_m, grade_smooth_m, target_grade + ) def measure( outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index f09392ef6..999230c66 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_LENGTH_M, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, NoRoute, RouteGraph, ) +from common_util.common_util_route_geometry import densify_route from common_util.common_util_route_polyline import build_planned_polyline ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} @@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05 SIMILAR_M = 10.0 # 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 CONSTRAINT_SHARE = 1.0 +# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼 +TRACE_DENSIFY_M = 2.0 Measure = Callable[..., dict[str, Any]] Route = tuple[Any, dict[str, Any]] @@ -103,14 +107,28 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판 + # 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 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), widen(floor, path, min_radius_m), fitted) + traced[key] = next( + ( + route + for route in ((outline, measure(outline, **kw)) for outline in larger) + if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"] + ), + least, + ) return traced[key] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 13ec86a8e..de8e9fc8f 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -1,8 +1,9 @@ """노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A). 후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다. -편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을 -바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. +편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을 +바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙 +(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. 계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다. diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py new file mode 100644 index 000000000..f425eaa70 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -0,0 +1,110 @@ +"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」). + +`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침. + +1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을 +[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M` +인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로. + +고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로 +펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다. +""" + +from __future__ import annotations + +import math + +import numpy as np +from shapely.geometry import LineString, Point + +from common_util.common_util_route_polyline import ( + PlannedPolyline, + _inner_angle_deg, + build_planned_polyline, +) +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +Xy = tuple[float, float] + + +def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]: + """곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴.""" + starts = {curve.node_first: curve for curve in outline.curves} + inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)} + points: list[Xy] = [] + flags: list[bool] = [] + radii: list[float | None] = [] + for index, node in enumerate(outline.nodes): + if index in inside: + continue + curve = starts.get(index) + points.append(curve.apex if curve else (node.x, node.y)) + flags.append(curve is not None) + radii.append(round(curve.radius_m, 2) if curve else None) + return points, flags, radii + + +def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray: + return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0] + + +def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray: + """R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m).""" + a = np.asarray(apex, dtype=float) + p = samples - a # (S, 2) 교각점 기준 + units = [(np.asarray(end, dtype=float) - a) for end in (before, after)] + halves = [float(np.hypot(*u)) / 2 for u in units] + units = [u / (2 * h) for u, h in zip(units, halves)] + inner = math.radians(_inner_angle_deg(before, apex, after)) + tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1) + legs = [] + for unit, half in zip(units, halves): + along = p @ unit + side = np.abs(_cross(unit, p)) + legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half)))) + bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1])) + center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2) + starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점 + q = p[None] - center # (M, S, 2) 중심 → 점 + turn = _cross(starts[0], starts[1]) + within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0) + arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf) + return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1) + + +def widen( + outline: PlannedPolyline, + path: list[Xy], + min_radius_m: float, + max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M, +) -> PlannedPolyline: + """곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.""" + if not outline.curves: + return outline + points, flags, radii = _flat(outline) + line = LineString(path) + coords = np.asarray(path, dtype=float) + along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + for index in range(1, len(points) - 1): + if not flags[index]: + continue + before, apex, after = points[index - 1], points[index], points[index + 1] + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + if half_tan <= 1e-9: + continue + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan + candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터 + # 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼 + ends = [ + line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2)) + for end in (before, after) + ] + samples = coords[(along >= min(ends)) & (along <= max(ends))] + if not candidates.size or not len(samples): + continue + ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m] + if ok.size: + radii[index] = float(ok[0]) + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py new file mode 100644 index 000000000..976917ab1 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -0,0 +1,128 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). + +같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 +셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). + +층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 +`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 +[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다. + +키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 +기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때 +다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임). +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncIterator + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + DEFAULT_WEIGHTS, + Terrain, + _pool_key, + resolve_inputs, + resolve_weights, +) +from common_util.common_util_json import atomic_write_json + +ALGO_VERSION = 2 +STORE_DIR = "B05_Profile/route_recommend" +# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 +DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") + +# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음) +_LOCKS: dict[str, asyncio.Lock] = {} + + +def store_dir(project_root: Path) -> Path: + return Path(project_root) / STORE_DIR + + +def request_fingerprint( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + mode: str, + weights: dict[str, float] | None, + max_grade: float, + min_radius_m: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, + with_profile: bool, + design_speed: int, +) -> tuple[str, str]: + """(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.""" + pool = _pool_key( + expected, + terrain, + streams, + max_grade, + min_radius_m, + *resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade), + ).hex() + used = resolve_weights(mode, weights) + tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION) + key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest() + return key, pool + + +def is_default( + mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any +) -> bool: + """기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음.""" + empty = not any(value is not None for value in (criteria or {}).values()) + plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS + return empty and plain and terrain is None + + +def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: + """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" + try: + data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: + return None + return data + + +def write_stored( + project_root: Path, + key: str, + pool: str, + request: dict[str, Any], + result: dict[str, Any], + default: bool, +) -> bool: + """새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음).""" + path = store_dir(project_root) / f"{key}.json" + if path.exists(): + return False + atomic_write_json( + path, + { + "version": ALGO_VERSION, + "key": key, + "pool": pool, + "default": default, + "created_at": datetime.now().isoformat(timespec="seconds"), + "request": request, + "result": result, + }, + ) + return True + + +@asynccontextmanager +async def pool_lock(pool: str) -> AsyncIterator[None]: + # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 + async with _LOCKS.setdefault(pool, asyncio.Lock()): + yield diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index b9e354689..5dc47a85a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]: 곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고, 받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다. """ + curves = _read_design_curve_table(project_root).get("curves") + return ( + [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] + ) + + +def load_station_snap(project_root: Path) -> bool: + """[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔.""" + return _read_design_curve_table(project_root).get("station_snap") is True + + +def _read_design_curve_table(project_root: Path) -> dict[str, Any]: + """설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict.""" route_path = planned_route_working_path(project_root) if not route_path.is_file(): route_path = planned_route_initial_path(project_root) target = route_path.with_name(f"{route_path.stem}_curves.json") if not target.is_file(): - return [] + return {} try: data = json.loads(target.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target) - return [] - curves = data.get("curves") if isinstance(data, dict) else None - return ( - [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] - ) + return {} + return data if isinstance(data, dict) else {} # ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ──────────── @@ -435,6 +445,7 @@ def run_section_generation( # 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라 # 종전의 측점별 실측 반경으로 물러선다. design_curves=_load_design_curves(project_root), + station_snap=load_station_snap(project_root), ) stage_root = project_root / _STAGE_SUBDIR @@ -548,6 +559,8 @@ def sample_structure_sections( crs=crs, # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. design_curves=_load_design_curves(project_root), + # 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남. + station_snap=load_station_snap(project_root), ) def structural(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 2505e8535..22e567aaa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -6,6 +6,9 @@ ⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` (`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`. +⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔ +`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험 +`resources/tester/test_224_2_station_snap.py`. """ import math @@ -275,6 +278,35 @@ def _design_curve_spans( return sorted(spans) +#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m). +CURVE_STATION_EDGE_M = 0.005 + + +def snap_curve_stations( + stations: np.ndarray | list[float], + spans: list[tuple[float, ...]], +) -> np.ndarray: + """측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC + (floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로. + + `spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리). + """ + values = [float(value) for value in stations] + for span in spans: + start, end = float(span[0]), float(span[1]) + middle = (start + end) / 2.0 + inside = [ + index + for index, value in enumerate(values) + if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M + ] + if not inside: + continue + nearest = min(inside, key=lambda index: abs(values[index] - middle)) + values[nearest] = float(math.floor(middle + 0.5)) + return np.unique(np.asarray(values, dtype=np.float64)) + + def _design_curve_widenings( station_chainage: np.ndarray, spans: list[tuple[float, float, float, str]], @@ -319,8 +351,12 @@ def generate_sections( source_snapshot: dict[str, Any] | None = None, crs: str | None = None, design_curves: list[dict[str, Any]] | None = None, + station_snap: bool = False, ) -> dict[str, Any]: - """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" + """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. + + `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2). + """ options = options or SectionGenerationOptions() options.validate() points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) @@ -331,6 +367,9 @@ def generate_sections( long_z, long_valid = sampler.sample_xy(long_xy) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) + spans = _design_curve_spans(points, route_chainage, design_curves or []) + if station_snap: + station_chainage = snap_curve_stations(station_chainage, spans) # 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다. regular_rounded = {round(float(value), 6) for value in station_chainage} # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 @@ -372,7 +411,6 @@ def generate_sections( # 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로 # 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다). # 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다. - spans = _design_curve_spans(points, route_chainage, design_curves or []) if spans: plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings( station_chainage, spans diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..744eb5a2c 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -26,7 +26,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) + POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) """ import asyncio @@ -41,10 +41,17 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import AliasChoices, BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice +from B05_Profile.B05_Profile_Engine_Sections import load_station_snap +from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 + RouteInitialCriteria, + RouteInitialRequest, + RouteInitialWeights, + RouteReplanRequest, + RouteVertexInput, +) from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( densify_route, @@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) _PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} -class RouteVertexInput(BaseModel): - """계획노선 꺾임점 하나 — 사업지 좌표계(m). - - 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — - · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. - · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. - · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. - """ - - x: float - y: float - curve: bool = True - """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" - radius_m: float | None = None - """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" - - -class RouteInitialWeights(BaseModel): - """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" - - expected: float = Field(50.0, ge=0, le=100) - earthwork: float = Field(25.0, ge=0, le=100) - grade: float = Field(25.0, ge=0, le=100) - - -class RouteInitialCriteria(BaseModel): - """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. - - 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 - 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. - """ - - max_grade_pct: float | None = Field(None, ge=0, le=100) - min_radius_m: float | None = Field(None, ge=0) - stream_offset_m: float | None = Field(None, ge=0) - grade_smooth_m: float | None = Field(None, ge=0) - # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) - target_grade_pct: float | None = Field(None, ge=0, le=100) - - -class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · - safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" - - mode: Literal[ - "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" - ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) - weights: RouteInitialWeights | None = None - criteria: RouteInitialCriteria | None = None - # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. - with_profile: bool = True - # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). - terrain: Literal["las", "sheet"] | None = None - - -class RouteReplanRequest(BaseModel): - """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" - - vertices: list[RouteVertexInput] = Field(default_factory=list) - initial: RouteInitialRequest | None = None - """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" - balanced_profile: bool = False - """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 - 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" - terrain: Literal["las", "sheet"] | None = None - """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" - - async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: """(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" pool = get_db_pool() @@ -305,9 +244,12 @@ def _write_planned_polyline( simplify: bool = True, curve_flags: list[bool] | None = None, radii: list[float | None] | None = None, + station_snap: bool | None = None, ) -> dict: """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔). + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. @@ -321,13 +263,13 @@ def _write_planned_polyline( ) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) - _curves_path(path).write_text( - json.dumps( - {"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]}, - ensure_ascii=False, - ), - encoding="utf-8", - ) + table: dict[str, Any] = { + "min_radius_m": round(radius_m, 3), + "curves": [c.as_dict() for c in result.curves], + } + if station_snap is not None: + table["station_snap"] = bool(station_snap) + _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") return { "nodes": len(result.nodes), "curves": result.curve_count, @@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), + "station_snap": await asyncio.to_thread(load_station_snap, project_root), "current_initial": await asyncio.to_thread(read_initial_choice, project_root), "initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m), } @@ -604,11 +547,17 @@ async def replan_route( radii = [vertex.radius_m for vertex in request.vertices] # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). + # 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음. working_path = planned_route_working_path(project_root) - previous = working_path.read_bytes() if working_path.is_file() else None + keep = (working_path, _curves_path(working_path), _nodes_path(working_path)) + previous = {path: path.read_bytes() if path.is_file() else None for path in keep} summary = await asyncio.to_thread( functools.partial( - _write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii + _write_planned_polyline, + simplify=False, + curve_flags=curve_flags, + radii=radii, + station_snap=request.station_snap, ), working_path, nodes, @@ -633,10 +582,11 @@ async def replan_route( ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. - if previous is None: - working_path.unlink(missing_ok=True) - else: - working_path.write_bytes(previous) + for path, saved in previous.items(): + if saved is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(saved) logger.warning( "계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s", project_id, diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 88e90e6b1..5bb6f494e 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -30,14 +30,35 @@ async def initial_route( ) -> dict[str, Any] | JSONResponse: """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). - **파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). + 작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로 + `POST /route/replan`([확인]). + """ + return await compute_initial(project_id, request) + + +async def compute_initial( + project_id: UUID, request: replan.RouteInitialRequest +) -> dict[str, Any] | JSONResponse: + """같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로. + + 응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import ( generate_initial_route, load_terrain_and_streams, ) - from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes + from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + cover_shortfall, + project_pipes, + ) + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + is_default, + pool_lock, + read_stored, + request_fingerprint, + write_stored, + ) from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, @@ -81,37 +102,62 @@ async def initial_route( async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, request.terrain) + inputs = { + "mode": request.mode, + "weights": weights, + "max_grade": max_grade, + "min_radius_m": radius_m, + "stream_offset_m": stream_offset_m, + "grade_smooth_m": grade_smooth_m, + "target_grade": None if target_pct is None else target_pct / 100.0, + "design_speed": design_speed or 20, + "with_profile": request.with_profile, + } started = time.perf_counter() try: terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = ( - await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None - ) - result = await asyncio.to_thread( - functools.partial( - generate_initial_route, - [(x, y) for x, y in expected], - terrain, - streams, - mode=request.mode, - weights=weights, - max_grade=max_grade, - min_radius_m=radius_m, - stream_offset_m=stream_offset_m, - grade_smooth_m=grade_smooth_m, - target_grade=None if target_pct is None else target_pct / 100.0, - design_speed=design_speed or 20, - pipes=pipes, - with_profile=request.with_profile, - ) + route = [(x, y) for x, y in expected] + key, pool_key = await asyncio.to_thread( + functools.partial(request_fingerprint, route, terrain, streams, **inputs) ) + async with pool_lock(pool_key): + stored = await asyncio.to_thread(read_stored, project_root, key) + if stored is not None: + result = stored["result"] + else: + # 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다. + result = await asyncio.to_thread( + functools.partial( + generate_initial_route, route, terrain, streams, pipes=None, **inputs + ) + ) + await asyncio.to_thread( + write_stored, + project_root, + key, + pool_key, + request.model_dump(), + result, + is_default( + request.mode, + weights, + criteria.model_dump() if criteria else None, + request.terrain, + ), + ) + profile = result["metrics"].get("profile") + if request.with_profile and profile and "error" not in profile: + pipes = await asyncio.to_thread(project_pipes, project_root) + if pipes: + profile.update(cover_shortfall(profile, result["planned"], pipes)) except (FileNotFoundError, ValueError) as error: return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) logger.info( - "초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", + "초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", request.mode, + "저장본" if stored is not None else "셈", project_id, time.perf_counter() - started, result["metrics"]["length_m"], @@ -122,10 +168,28 @@ async def initial_route( "status": "success", "project_id": str(project_id), **result, + "stored": stored is not None, "current_initial": current, } +async def precompute_defaults(project_id: UUID) -> None: + """업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만.""" + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + + started = time.perf_counter() + for mode in DEFAULT_MODES: + # 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`) + request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow") + outcome = await compute_initial(project_id, request) + if isinstance(outcome, JSONResponse): + logger.warning( + "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id + ) + return + logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) + + def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" import json diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 559d64724..197578e4b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel): #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. cross_half_width_m: float | None = Field(None, gt=0) terrain: AnalysisTerrain | None = None + #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. + station_snap: bool = False def _cross_preview( @@ -177,7 +179,14 @@ def _cross_preview( else defaults.station_interval_m, cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, ) - result = generate_sections(built.vertices, sampler, options) + # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. + result = generate_sections( + built.vertices, + sampler, + options, + design_curves=[curve.as_dict() for curve in built.curves], + station_snap=request.station_snap, + ) sections = result["cross_sections"] if not sections: return None diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0578c7334..5221c9d7d 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from config.config_system import ( @@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel): route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None + + +# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ───────────────────────────── + + +class RouteVertexInput(BaseModel): + """계획노선 꺾임점 하나 — 사업지 좌표계(m). + + 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — + · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. + · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. + · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. + """ + + x: float + y: float + curve: bool = True + """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" + radius_m: float | None = None + """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" + + +class RouteInitialWeights(BaseModel): + """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" + + expected: float = Field(50.0, ge=0, le=100) + earthwork: float = Field(25.0, ge=0, le=100) + grade: float = Field(25.0, ge=0, le=100) + + +class RouteInitialCriteria(BaseModel): + """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. + + 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 + 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. + """ + + max_grade_pct: float | None = Field(None, ge=0, le=100) + min_radius_m: float | None = Field(None, ge=0) + stream_offset_m: float | None = Field(None, ge=0) + grade_smooth_m: float | None = Field(None, ge=0) + # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) + target_grade_pct: float | None = Field(None, ge=0, le=100) + + +class RouteInitialRequest(BaseModel): + """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · + safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" + + mode: Literal[ + "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" + ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) + weights: RouteInitialWeights | None = None + criteria: RouteInitialCriteria | None = None + # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. + with_profile: bool = True + # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). + terrain: Literal["las", "sheet"] | None = None + + +class RouteReplanRequest(BaseModel): + """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" + + vertices: list[RouteVertexInput] = Field(default_factory=list) + initial: RouteInitialRequest | None = None + """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" + balanced_profile: bool = False + """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 + 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" + terrain: Literal["las", "sheet"] | None = None + """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" + station_snap: bool = False + """[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음.""" diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index d8d32dcd5..75a2d3ab5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -536,8 +536,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin: (index) => selectBasin(index), }); - // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 - // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). + // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다(늘릴 때마다 확대되면 보던 자리를 잃음). const resizeObserver = observeViewportSize(viewport, { meta: () => meta, anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), @@ -696,5 +695,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra resizeObserver.disconnect(); widthResizer.dispose(); }, + rehome: () => resizeObserver.rehome(), }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 7ed1fe8b7..365d8a1ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,6 +52,8 @@ export interface DrainagePanel { /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ setIntervalSpans: (spans: ReadonlyArray) => void; + /** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */ + rehome: () => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 868e450c5..f6e4539ba 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -23,6 +23,7 @@ import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { viewOf } from "../common_util/common_util_view_window"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -454,7 +455,8 @@ export function preserveViewOnResize( }; } -/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ +/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. `rehome` = 판이 다른 창으로 옮겨 간 뒤 그 창의 + * 관찰자로 바꿔 낌(직전 크기를 지켜 배율 유지 · 325-1) · `disconnect` = 정리. */ export function observeViewportSize( viewport: HTMLElement, ports: { @@ -463,9 +465,9 @@ export function observeViewportSize( apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; redraw: () => void; }, -): ResizeObserver { +): { rehome: () => void; disconnect: () => void } { let last: { width: number; height: number } | null = null; - const observer = new ResizeObserver(() => { + const onResize = (): void => { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); @@ -482,9 +484,22 @@ export function observeViewportSize( } last = { width, height }; ports.redraw(); - }); - observer.observe(viewport); - return observer; + }; + // 새 창 크기 변화는 그 창의 관찰자만 안다 — 판이 놓인 창의 것으로 단다. + const watch = (): ResizeObserver => { + const view = viewOf(viewport) as Window & typeof globalThis; + const next = new view.ResizeObserver(onResize); + next.observe(viewport); + return next; + }; + let observer = watch(); + return { + rehome: () => { + observer.disconnect(); + observer = watch(); + }, + disconnect: () => observer.disconnect(), + }; } /** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index b5c22166a..4bb99cbf0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -133,6 +133,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, @@ -143,7 +145,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; + const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, @@ -169,7 +171,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { let mainDragRef: { chart: number; mass: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; + const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { chart: Math.max( @@ -191,7 +193,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 3771fdf1e..fe6035427 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -67,7 +67,8 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - const massOpen = !deps.massHaul.overlay.hidden; + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 0600b48e1..d219230b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -438,7 +438,12 @@ export function createRouteMassHaulPanel( window: undefined, hideStations: true, } - : { ...params.axis, window: windowState }; + : { + ...params.axis, + // 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2). + viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange, + window: windowState, + }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a69bcf658..65d2759b3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -59,6 +59,7 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { curveSpans, snapCurveStations, stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame"; import { lockColumn, readRouteLocks, @@ -109,7 +110,7 @@ export async function openRouteEditModal( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; - const { minorContourButton, terrainSelect } = chrome; + const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -138,6 +139,24 @@ export async function openRouteEditModal( let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */ + let stationSnap = false; + let savedStationSnap = false; + /** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로. + * 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */ + const stations = (): number[] | null => { + if (!stationSnap) return null; + const line = plannedLine.length ? plannedLine : planned; + return snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curveInfo), + ); + }; + const setStationSnap = (on: boolean): void => { + stationSnap = on; + stationSnapButton.classList.toggle("is-active", on); + stationSnapButton.setAttribute("aria-pressed", String(on)); + }; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; @@ -165,6 +184,7 @@ export async function openRouteEditModal( })), min_radius_m: minRadiusM || 12, station_interval_m: stationIntervalM, + station_snap: stationSnap, }), onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); @@ -217,6 +237,11 @@ export async function openRouteEditModal( const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); + stationSnapButton.addEventListener("click", () => { + setStationSnap(!stationSnap); + void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. + draw(); + }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ let viewReady = false; const viewMemory = createViewMemory(projectId, () => @@ -307,6 +332,7 @@ export async function openRouteEditModal( toScreen, uprightRad: rotation.uprightRad(), dragging: pointer.dragging(), + chainages: stations(), }), measure: measure.marks(), crossWidth: crossPreview.watched(), @@ -319,6 +345,7 @@ export async function openRouteEditModal( locked: apexLock, picked, stationIntervalM, + stations: stations(), }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); @@ -448,6 +475,7 @@ export async function openRouteEditModal( canvas, status, stationIntervalM, + stations, state: () => ({ planned, plannedLine, @@ -530,8 +558,12 @@ export async function openRouteEditModal( projectId, nodes: () => ({ planned, curveOn, curveRadius }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). - changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain, + changed: () => + (history?.isDirty() ?? false) || + pickedTerrain() !== designTerrain || + stationSnap !== savedStationSnap, terrain: pickedTerrain, + stationSnap: () => stationSnap, initial, profileShown: strip.settle, close, @@ -554,6 +586,8 @@ export async function openRouteEditModal( minRadiusM = plan.min_radius_m ?? 0; limitRadiusM = plan.limit_radius_m ?? 0; limitArcM = plan.limit_curve_length_m ?? 0; + savedStationSnap = plan.station_snap === true; + setStationSnap(savedStationSnap); // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index ed8036b2a..05f05f7d2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -27,6 +27,8 @@ export interface RouteApplyParams { changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; + /** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */ + stationSnap: () => boolean; /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; @@ -97,6 +99,7 @@ export function bindRouteApply(params: RouteApplyParams): void { // 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. balanced_profile: await params.profileShown(), terrain: params.terrain(), + station_snap: params.stationSnap(), }, ), ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index fb0c4208d..c6e66feee 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -36,6 +36,8 @@ export interface RouteEditChrome { measureText: HTMLElement; measureButton: HTMLButtonElement; minorContourButton: HTMLButtonElement; + /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ + stationSnapButton: HTMLButtonElement; /** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */ terrainSelect: HTMLSelectElement; } @@ -65,6 +67,9 @@ export function createRouteEditChrome(): RouteEditChrome { title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선 + @@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome { '[data-act="contour-minor"]', )!; const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; + const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!; return { overlay, @@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome { measureText, measureButton, minorContourButton, + stationSnapButton, terrainSelect, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 1c6a3648c..c0b491141 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -35,6 +35,7 @@ export interface CrossPreviewParams { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; station_interval_m: number; + station_snap?: boolean; }; /** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */ onShown?: () => void; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts index f7e1f4b56..67bc9aeb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -5,6 +5,10 @@ * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at` * (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고 * 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림. + * + * ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` + * ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` · + * `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`. * ========================================================================== */ type Vertex = [number, number]; @@ -75,3 +79,98 @@ export function stationFrameAt( const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length]; return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] }; } + +/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */ +export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0; +/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */ +export const CURVE_STATION_EDGE_M = 0.005; + +/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */ +export interface CurveEnds { + start?: ReadonlyArray | null; + apex?: ReadonlyArray | null; + end?: ReadonlyArray | null; + radius_m?: number | null; +} + +/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ +export function stationsAlong( + line: ReadonlyArray, + intervalM: number, + includeEndpoint: boolean, +): number[] { + const { chainage } = cleanPolyline(line); + const total = chainage.length ? chainage[chainage.length - 1] : 0; + const values: number[] = []; + if (intervalM > 0) + for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM); + if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0); + if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total); + return values; +} + +/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */ +function projectChainage( + points: Vertex[], + chainage: number[], + xy: ReadonlyArray, +): [number, number] { + let bestGap = Infinity; + let bestAt = 0; + for (let index = 0; index < points.length - 1; index += 1) { + const [sx, sy] = points[index]; + const vx = points[index + 1][0] - sx; + const vy = points[index + 1][1] - sy; + const length2 = vx * vx + vy * vy; + const safe = length2 > 1e-12 ? length2 : 1; + const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe)); + const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]); + if (gap < bestGap) { + bestGap = gap; + bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio; + } + } + return [bestGap, bestAt]; +} + +/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림. + * `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */ +export function curveSpans( + line: ReadonlyArray, + curves: ReadonlyArray, +): Array<[number, number]> { + const { points, chainage } = cleanPolyline(line); + if (points.length < 2) return []; + const spans: Array<[number, number]> = []; + for (const { start, apex, end, radius_m } of curves) { + if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue; + const [startGap, startAt] = projectChainage(points, chainage, start); + const [endGap, endAt] = projectChainage(points, chainage, end); + if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue; + const cross = + (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]); + if (Math.abs(cross) < 1e-12) continue; + spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]); + } + return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 · + * 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */ +export function snapCurveStations( + stations: ReadonlyArray, + spans: ReadonlyArray, +): number[] { + const values = [...stations]; + for (const [start, end] of spans) { + const middle = (start + end) / 2; + let nearest = -1; + values.forEach((value, index) => { + if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return; + if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle)) + nearest = index; + }); + if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5); + } + return [...new Set(values)].sort((a, b) => a - b); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 6e63790ff..7199fa9b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -189,6 +189,8 @@ export interface InitialChoice { similar?: SimilarRoute[] | null; /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ bandWidthM?: number | null; + /** 서버 추천 저장본에서 꺼낸 결과인가(계획서 327) — 서버 `stored`. */ + stored?: boolean; /** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */ balance?: BalanceProfile | null; } @@ -336,6 +338,7 @@ export function normalizeRouteInitial( terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, similar: parseSimilar(body.similar), bandWidthM: num(body.band_width_m), + stored: body.stored === true, balance: parseBalance(m.profile), criteria: body.criteria ? { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index fbef8b083..4eef74ec3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -321,13 +321,15 @@ export function stationAtScreen( py: number, maxPx: number, labels: ReadonlyArray = [], + /** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */ + chainages: ReadonlyArray | null = null, ): number | null { // 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이 // 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다. const shown = labels.length ? new Set(labels.map((box) => Math.round(box.chainage / intervalM))) : null; - const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown); + const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages); // 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다. return tick ?? stationLabelAtScreen(labels, px, py); } @@ -373,6 +375,7 @@ function stationTickAtScreen( py: number, maxPx: number, shown: ReadonlySet | null, + chainages: ReadonlyArray | null, ): number | null { if (line.length < 2 || !(intervalM > 0)) return null; const cumulative: number[] = [0]; @@ -386,7 +389,10 @@ function stationTickAtScreen( let best: number | null = null; let bestDistance = maxPx; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += intervalM) { + const marks: number[] = []; + if (chainages) marks.push(...chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += intervalM) marks.push(chainage); + for (const chainage of marks) { if (shown && !shown.has(Math.round(chainage / intervalM))) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = line[cursor - 1]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 3763161ea..2da4c4f10 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -64,6 +64,8 @@ export interface RouteEditPointerParams { canvas: HTMLCanvasElement; status: HTMLElement; stationIntervalM: number; + /** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: () => ReadonlyArray | null; state: () => RouteEditPointerState; setPicked: (index: number) => void; setPickedContour: (index: number) => void; @@ -194,6 +196,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP py, pickPx(STATION_HIT_PX), stationLabelBoxes(canvas), + params.stations(), ); if (chainage === null) return false; void params.crossPreview.open(chainage); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 6d8a4f854..d42d94aef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -462,7 +462,9 @@ export function openRecommendModal(params: RecommendParams): void { preview = choice; if (choice.balance) seen.set(label, choice.balance); else seen.delete(label); // 종단 없는 갈래(예상노선 추적) - stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`; + stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${ + choice.stored ? " (저장된 결과)" : "" + } — [적용]을 눌러야 편집 창에 놓입니다.`; } catch (error) { if (closed || mine !== seq) return; stateEl.textContent = "셈하지 못했습니다."; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index c3c713495..d088f80b7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -29,7 +29,7 @@ const LOGIC_HELP: Record = { follow: { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ - "새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음", + "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", "전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴", "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", ], diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 3a35c064e..90c6664ec 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -94,6 +94,8 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; + /** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: ReadonlyArray | null; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray; /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ @@ -478,6 +480,7 @@ function drawStationMarks( toScreen: (x, y) => scene.toScreen([x, y]), uprightRad: scene.uprightRad, onLabel: (box) => labels.push(box), + chainages: scene.stations ?? undefined, }, ); const total = polylineLengthM(line); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts index e35789f81..aa372ee5b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -42,6 +42,8 @@ export interface SlopeDrawArgs { uprightRad: number; /** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */ dragging: boolean; + /** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + chainages?: ReadonlyArray | null; } export interface SlopeLabels { @@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { /** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */ let cachedLine: ReadonlyArray | null = null; let cachedInterval = 0; + /** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */ + let cachedKey = ""; let chainages: number[] = []; let heights: Array = []; /** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */ @@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { let asking: ReadonlyArray | null = null; let timer: ReturnType | null = null; - function schedule(line: ReadonlyArray, intervalM: number): void { + function schedule( + line: ReadonlyArray, + intervalM: number, + marks: ReadonlyArray | null, + ): void { if (asking === line) return; if (timer) clearTimeout(timer); asking = line; timer = setTimeout(() => { timer = null; - const wanted = stationChainages(lengthOf(line), intervalM); + const total = lengthOf(line); + const wanted = marks + ? [...marks.filter((value) => value < total - 1e-6), total] + : stationChainages(total, intervalM); const points = pointsAt(line, wanted); void fetchRouteElevations(params.projectId, points) .then((z) => { if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다. cachedLine = line; cachedInterval = intervalM; + cachedKey = marks?.join() ?? ""; chainages = wanted; heights = z; stationPoints = points; @@ -160,9 +172,11 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { const { line, intervalM } = args; if (line.length < 2 || !(intervalM > 0)) return 0; if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다. - const fresh = cachedLine === line && cachedInterval === intervalM; + const marks = args.chainages ?? null; + const fresh = + cachedLine === line && cachedInterval === intervalM && cachedKey === (marks?.join() ?? ""); if (!fresh) { - if (!args.dragging) schedule(line, intervalM); + if (!args.dragging) schedule(line, intervalM, marks); return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다. } // 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 — diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 6833d8a20..53ff81b73 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -89,7 +89,10 @@ if (typeof document !== "undefined") /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { - return !!lastPointer?.closest(".b05-route-profile"); + // 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2). + return !!lastPointer?.closest( + ".b05-route-profile, .b05-profile__masshaul-overlay, .b05-drainage", + ); } /** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김. diff --git a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json new file mode 100644 index 000000000..634771019 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json @@ -0,0 +1,155 @@ +[ + {"id": "LINE", "label": "선", "aliases": ["L"]}, + {"id": "PLINE", "label": "폴리선", "aliases": ["PL"]}, + {"id": "CIRCLE", "label": "원", "aliases": ["C"]}, + {"id": "ARC", "label": "호", "aliases": ["A"]}, + {"id": "RECTANG", "label": "직사각형", "aliases": ["REC"]}, + {"id": "POLYGON", "label": "다각형", "aliases": ["POL"]}, + {"id": "ELLIPSE", "label": "타원", "aliases": ["EL"]}, + {"id": "SPLINE", "label": "스플라인", "aliases": ["SPL"]}, + {"id": "MLINE", "label": "다중선", "aliases": ["ML"]}, + {"id": "MLSTYLE", "label": "다중선 스타일", "aliases": []}, + {"id": "XLINE", "label": "구성선", "aliases": ["XL"]}, + {"id": "RAY", "label": "광선", "aliases": []}, + {"id": "POINT", "label": "점", "aliases": ["PO"]}, + {"id": "DONUT", "label": "도넛", "aliases": ["DO"]}, + {"id": "DIVIDE", "label": "등분", "aliases": ["DIV"]}, + {"id": "MEASURE", "label": "길이분할", "aliases": ["ME"]}, + {"id": "HATCH", "label": "해치", "aliases": ["H", "BH"]}, + {"id": "GRADIENT", "label": "그라데이션", "aliases": ["GD"]}, + {"id": "BOUNDARY", "label": "경계", "aliases": ["BO"]}, + {"id": "REGION", "label": "영역", "aliases": ["REG"]}, + {"id": "WIPEOUT", "label": "와이프아웃", "aliases": []}, + {"id": "MOVE", "label": "이동", "aliases": ["M"]}, + {"id": "COPY", "label": "복사", "aliases": ["CO", "CP"]}, + {"id": "ROTATE", "label": "회전", "aliases": ["RO"]}, + {"id": "SCALE", "label": "축척", "aliases": ["SC"]}, + {"id": "ALIGN", "label": "정렬", "aliases": ["AL"]}, + {"id": "MIRROR", "label": "대칭", "aliases": ["MI"]}, + {"id": "OFFSET", "label": "간격띄우기", "aliases": ["O"]}, + {"id": "ARRAY", "label": "배열", "aliases": ["AR"]}, + {"id": "TRIM", "label": "자르기", "aliases": ["TR"]}, + {"id": "EXTEND", "label": "연장", "aliases": ["EX"]}, + {"id": "STRETCH", "label": "신축", "aliases": ["S"]}, + {"id": "FILLET", "label": "모깎기", "aliases": ["F"]}, + {"id": "CHAMFER", "label": "모따기", "aliases": ["CHA"]}, + {"id": "BLEND", "label": "곡선 혼합", "aliases": []}, + {"id": "BREAK", "label": "끊기", "aliases": ["BR"]}, + {"id": "BREAKATPOINT", "label": "점에서 끊기", "aliases": []}, + {"id": "JOIN", "label": "결합", "aliases": ["J"]}, + {"id": "EXPLODE", "label": "분해", "aliases": ["X"]}, + {"id": "ERASE", "label": "지우기", "aliases": ["E"]}, + {"id": "LENGTHEN", "label": "길이조정", "aliases": ["LEN"]}, + {"id": "PEDIT", "label": "폴리선 편집", "aliases": ["PE"]}, + {"id": "HATCHEDIT", "label": "해치 편집", "aliases": ["HE"]}, + {"id": "DRAWORDER", "label": "맨 앞으로", "aliases": ["DR"]}, + {"id": "DRAWORDERBACK", "label": "맨 뒤로", "aliases": []}, + {"id": "MATCHPROP", "label": "특성 일치", "aliases": ["MA"]}, + {"id": "OVERKILL", "label": "중복 객체 삭제", "aliases": []}, + {"id": "REVERSE", "label": "방향 반전", "aliases": []}, + {"id": "SETBYLAYER", "label": "ByLayer로", "aliases": []}, + {"id": "ALIGNLEFT", "label": "왼쪽", "aliases": []}, + {"id": "ALIGNCENTERH", "label": "가로 중앙", "aliases": []}, + {"id": "ALIGNRIGHT", "label": "오른쪽", "aliases": []}, + {"id": "ALIGNTOP", "label": "위", "aliases": []}, + {"id": "ALIGNCENTERV", "label": "세로 중앙", "aliases": []}, + {"id": "ALIGNBOTTOM", "label": "아래", "aliases": []}, + {"id": "MTEXT", "label": "여러 줄 문자", "aliases": ["T", "MT"]}, + {"id": "TEXT", "label": "단일 행 문자", "aliases": ["DT"]}, + {"id": "STYLE", "label": "문자 스타일", "aliases": ["ST"]}, + {"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["ED", "TEDIT"]}, + {"id": "FIND", "label": "찾기·대치", "aliases": []}, + {"id": "DIM", "label": "치수", "aliases": []}, + {"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]}, + {"id": "DIMALIGNED", "label": "정렬 치수", "aliases": ["DAL"]}, + {"id": "DIMANGULAR", "label": "각도 치수", "aliases": ["DAN"]}, + {"id": "DIMARC", "label": "호 길이 치수", "aliases": ["DAR"]}, + {"id": "DIMRADIUS", "label": "반지름 치수", "aliases": ["DRA"]}, + {"id": "DIMDIAMETER", "label": "지름 치수", "aliases": ["DDI"]}, + {"id": "DIMORDINATE", "label": "세로좌표 치수", "aliases": ["DOR"]}, + {"id": "DIMBASELINE", "label": "기준선 치수", "aliases": ["DBA"]}, + {"id": "DIMCONTINUE", "label": "연속 치수", "aliases": ["DCO"]}, + {"id": "QDIM", "label": "빠른 치수", "aliases": []}, + {"id": "CENTERMARK", "label": "중심 표식", "aliases": []}, + {"id": "CENTERLINE", "label": "중심선", "aliases": []}, + {"id": "DIMSTYLE", "label": "치수 스타일", "aliases": ["D"]}, + {"id": "DIMUPDATE", "label": "치수 업데이트", "aliases": []}, + {"id": "DIMSPACE", "label": "치수 간격", "aliases": []}, + {"id": "MLEADER", "label": "다중 지시선", "aliases": ["MLD"]}, + {"id": "MLEADERSTYLE", "label": "지시선 스타일", "aliases": ["MLS"]}, + {"id": "MLEADERALIGN", "label": "지시선 정렬", "aliases": []}, + {"id": "TABLE", "label": "테이블", "aliases": ["TB"]}, + {"id": "TABLESTYLE", "label": "테이블 스타일", "aliases": ["TS"]}, + {"id": "TABLEEDIT", "label": "칸 편집", "aliases": []}, + {"id": "TABLEROW", "label": "행 넣기·지우기", "aliases": []}, + {"id": "TABLECOL", "label": "열 넣기·지우기", "aliases": []}, + {"id": "TABLEMERGE", "label": "칸 병합", "aliases": []}, + {"id": "TABLEUNMERGE", "label": "병합 해제", "aliases": []}, + {"id": "PLACETABLE", "label": "표 놓기", "aliases": []}, + {"id": "REVCLOUD", "label": "구름형 리비전", "aliases": []}, + {"id": "ANNOSCALE", "label": "주석 축척", "aliases": []}, + {"id": "LAYER", "label": "도면층 특성", "aliases": ["LA"]}, + {"id": "LAYCURSET", "label": "현재 도면층 설정", "aliases": []}, + {"id": "LAYOFF", "label": "도면층 끄기", "aliases": []}, + {"id": "LAYON", "label": "모두 켜기", "aliases": []}, + {"id": "LAYFRZ", "label": "동결", "aliases": []}, + {"id": "LAYTHW", "label": "동결 해제", "aliases": []}, + {"id": "LAYLCK", "label": "잠금", "aliases": []}, + {"id": "LAYULK", "label": "잠금 해제", "aliases": []}, + {"id": "LAYISO", "label": "분리", "aliases": []}, + {"id": "LAYUNISO", "label": "분리 해제", "aliases": []}, + {"id": "LAYERP", "label": "이전 상태", "aliases": []}, + {"id": "LAYERSTATE", "label": "도면층 상태 저장", "aliases": ["LAS"]}, + {"id": "LAYERSTATERESTORE", "label": "도면층 상태 복원", "aliases": []}, + {"id": "LAYMCH", "label": "도면층 일치", "aliases": []}, + {"id": "LAYCUR", "label": "현재 도면층으로", "aliases": []}, + {"id": "LAYMRG", "label": "도면층 병합", "aliases": []}, + {"id": "LAYDEL", "label": "도면층 삭제", "aliases": []}, + {"id": "LAYWALK", "label": "도면층 탐색", "aliases": []}, + {"id": "COLOR", "label": "색상", "aliases": ["COL"]}, + {"id": "LINETYPE", "label": "선종류", "aliases": ["LT"]}, + {"id": "LWEIGHT", "label": "선가중치", "aliases": ["LW"]}, + {"id": "PROPERTIES", "label": "특성", "aliases": ["CH", "MO", "PR"]}, + {"id": "QUICKPROPERTIES", "label": "빠른 특성", "aliases": ["QP"]}, + {"id": "TRANSPARENCY", "label": "투명도", "aliases": []}, + {"id": "GROUP", "label": "그룹", "aliases": ["G"]}, + {"id": "UNGROUP", "label": "그룹 해제", "aliases": []}, + {"id": "DIST", "label": "거리", "aliases": ["DI"]}, + {"id": "RADIUS", "label": "반지름", "aliases": []}, + {"id": "ANGLE", "label": "각도", "aliases": []}, + {"id": "AREA", "label": "면적", "aliases": ["AA"]}, + {"id": "MEASUREGEOM", "label": "빠른 측정", "aliases": ["MEA"]}, + {"id": "ID", "label": "점 좌표", "aliases": []}, + {"id": "LIST", "label": "리스트", "aliases": ["LI"]}, + {"id": "QSELECT", "label": "빠른 선택", "aliases": []}, + {"id": "QUICKCALC", "label": "계산기", "aliases": ["QC"]}, + {"id": "SELECTSIMILAR", "label": "유사 선택", "aliases": []}, + {"id": "ISOLATEOBJECTS", "label": "객체 분리", "aliases": []}, + {"id": "HIDEOBJECTS", "label": "객체 숨기기", "aliases": []}, + {"id": "UNISOLATEOBJECTS", "label": "객체 분리 종료", "aliases": ["UNHIDE", "UNISOLATE"]}, + {"id": "COPYCLIP", "label": "복사", "aliases": []}, + {"id": "CUTCLIP", "label": "잘라내기", "aliases": []}, + {"id": "PASTECLIP", "label": "붙여넣기", "aliases": []}, + {"id": "COPYBASE", "label": "기준점 복사", "aliases": []}, + {"id": "PASTEORIG", "label": "원래 좌표로 붙여넣기", "aliases": []}, + {"id": "PASTEBLOCK", "label": "그룹으로 붙여넣기", "aliases": []}, + {"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]}, + {"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]}, + {"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]}, + {"id": "SELECT", "label": "선택", "aliases": []}, + {"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]}, + {"id": "ZOOMIN", "label": "확대", "aliases": []}, + {"id": "ZOOMOUT", "label": "축소", "aliases": []}, + {"id": "REGEN", "label": "재생성", "aliases": ["RE"]}, + {"id": "PAN", "label": "초점이동", "aliases": ["P"]}, + {"id": "QSAVE", "label": "저장", "aliases": ["SAVE"]}, + {"id": "RECOVER", "label": "백업 복구", "aliases": []}, + {"id": "EXPORT", "label": "JSON 내보내기", "aliases": ["EXP"]}, + {"id": "EXPORTDXF", "label": "DXF 내보내기", "aliases": []}, + {"id": "EXPORTDWG", "label": "DWG 내보내기", "aliases": []}, + {"id": "EXPORTSVG", "label": "SVG 내보내기", "aliases": []}, + {"id": "EXPORTPNG", "label": "PNG 내보내기", "aliases": []}, + {"id": "TOAREA", "label": "면적산출로 보내기", "aliases": []}, + {"id": "UNDO", "label": "실행 취소", "aliases": ["U"]}, + {"id": "REDO", "label": "다시 실행", "aliases": []} +] diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index e2583de55..0d76c9744 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -81,7 +81,7 @@ export const TEXT_COMMANDS: CadCommand[] = [ { id: 'TEXTEDIT', label: '문자 편집', - aliases: ['TEDIT'], + aliases: ['ED', 'TEDIT'], glyph: '✎', hint: '기존 문자의 내용을 고친다', tool: Tool.TEXTEDIT, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index f5978a9bd..6c9992909 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -7,7 +7,8 @@ import { requestDrawingExport, requestHostSave } from '../integration/aislo-draw import { runSendSelectionTable } from '../integration/aislo-table-export'; import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; -import { isHostSave, redo, undo } from '../state'; +import { redoCommand, undoCommand } from '../inputController/prompt-input'; +import { isHostSave } from '../state'; import type { CadCommand } from './command.types'; export const FILE_COMMANDS: CadCommand[] = [ @@ -102,7 +103,7 @@ export const FILE_COMMANDS: CadCommand[] = [ aliases: ['U'], glyph: '↶', run: () => { - undo(); + undoCommand(); // 끊고 뒤 되돌림 — Ctrl+Z 와 한 길 return '실행 취소'; }, }, @@ -111,7 +112,7 @@ export const FILE_COMMANDS: CadCommand[] = [ label: '다시 실행', glyph: '↷', run: () => { - redo(); + redoCommand(); return '다시 실행'; }, }, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts index 14e078a66..a4bae8470 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -17,6 +17,7 @@ import { chamferToolStateMachine, filletToolStateMachine, } from '../tools/modify/corner-tools'; +import { lengthenToolStateMachine } from '../tools/modify/lengthen-tool'; import { drawOrderBackToolStateMachine, drawOrderFrontToolStateMachine, @@ -32,11 +33,10 @@ import { extendToolStateMachine, joinToolStateMachine, } from '../tools/modify/structure-tools'; +import { offsetToolStateMachine } from '../tools/modify/offset-tool'; import { alignToolStateMachine, - lengthenToolStateMachine, mirrorToolStateMachine, - offsetToolStateMachine, stretchToolStateMachine, } from '../tools/modify/transform-tools'; import { moveToolStateMachine } from '../tools/move-tool'; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 0c21cedee..044259c38 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -6,6 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version'; import { getScreenCanvasDrawController, setEntities, getEntities } from '../state'; import { Tool } from '../tools'; import { selectToolStateMachine } from '../tools/select-tool'; +import { startZoomPrompt } from '../inputController/zoom-prompt'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); @@ -32,7 +33,6 @@ export const VIEW_COMMANDS: CadCommand[] = [ { id: 'SELECT', label: '선택', - aliases: ['SE'], glyph: '↖', hint: '객체를 클릭하거나 선택 사각형으로 고른다', tool: Tool.SELECT, @@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [ label: '범위 줌', aliases: ['Z'], glyph: '⛶', - hint: '도면 전체가 보이도록 화면을 맞춘다', + hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)', run: () => { - getScreenCanvasDrawController().zoomToFitScreen(); - return '도면 전체 보기'; + startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길 + return ''; }, }, { diff --git a/B07_DesignDetail/openwebcad/src/commands/prompt.ts b/B07_DesignDetail/openwebcad/src/commands/prompt.ts new file mode 100644 index 000000000..5efa82684 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/prompt.ts @@ -0,0 +1,177 @@ +/** + * 물음 꼴(337 설계 2-3 · 2-4 · 2-9) — 오토캐드 명령행 한 줄 「글 또는 [이름(키)/…] <기본>:」 · + * 옵션 맞추기(initget 줄임 규칙) · 세션 기억(앞에 쓴 값이 다음 <기본>). + * 도구는 상태 `meta: { kind, prompt }` 로 물음을 선언하고 `OPTION.` 사건으로 옵션을 받는다. + * 이 파일은 상태(state.ts)를 읽지 않음 — 기억은 탭 수명 Map 하나. + */ +import type { StepKind } from '../tools/tool.types'; + +export type XY = { x: number; y: number }; + +/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.` · 맞추기는 kw · en */ +export interface OptionSpec { + key: string; + kw: string; + en: string; + label: string; + hidden?: true; +} + +export type DefaultSpec = + | { kind: 'value'; remember: string; initial: number | XY | null; format?: 'len' | 'int' | 'ang' } + /** <변위> · <전체(T)>(label 로 바꿈) — 빈 Enter = `OPTION.` */ + | { kind: 'option'; key: string; label?: string } + /** <첫 번째 점을 변위로 사용> · <종료> · <객체 선택> — 빈 Enter = `ENTER` */ + | { kind: 'enter'; label: string }; + +export interface PromptSpec { + /** '다음 점 지정' — ' 또는 [..]' · ' <..>' · ':' 는 renderPrompt 가 붙임 */ + text: string; + /** 넣은 옵션(숨김 포함) */ + options?: OptionSpec[]; + /** 오토캐드엔 있고 우리엔 없음 — 치면 안내 · 안 그림 */ + absent?: OptionSpec[]; + default?: DefaultSpec; + number?: { int?: true; min?: number; max?: number; positive?: true }; + /** 거절 글 바꿈(없으면 물음 종류 기본 글) */ + reject?: string; + /** ↑↓ 최근 값 묶음(없으면 text) */ + history?: string; +} + +// biome-ignore lint/suspicious/noExplicitAny: 도구마다 context 꼴이 다름 +export type PromptLike = PromptSpec | ((context: any) => PromptSpec); + +/** 상태 meta 꼴 — `instructions` 는 옛 꼴(전환 중) */ +export interface PromptMeta { + kind?: StepKind; + prompt?: PromptLike; + instructions?: string; +} + +/** 함수 꼴 물음을 지금 context 로 풂 */ +export const resolvePrompt = (prompt: PromptLike | undefined, context: unknown): PromptSpec | null => + prompt ? (typeof prompt === 'function' ? prompt(context) : prompt) : null; + +// ---- 세션 기억(설계 2-9) ---- + +const memory = new Map(); +let lastPicked: string[] = []; +let lastPointValue: XY | null = null; + +export const remember = (key: string, value: unknown): void => { + memory.set(key, value); +}; +export const recall = (key: string): T | undefined => memory.get(key) as T | undefined; +/** 고르기 이전(P) — 명령이 고른 것을 쓸 때 */ +export const rememberSelection = (ids: string[]): void => { + lastPicked = [...ids]; +}; +export const previousSelection = (): string[] => lastPicked; +/** 마지막 점(LASTPOINT) — `@` 의 기준 */ +export const rememberPoint = (point: XY): void => { + lastPointValue = { x: point.x, y: point.y }; +}; +export const lastPoint = (): XY | null => lastPointValue; + +// ---- 그림(설계 2-3) ---- + +const isXY = (value: unknown): value is XY => + typeof value === 'object' && + value !== null && + typeof (value as XY).x === 'number' && + typeof (value as XY).y === 'number'; + +/** 기본 값 — 기억 우선 · 없으면 처음 값 */ +export function defaultValue(spec: DefaultSpec & { kind: 'value' }): number | XY | null { + const remembered = recall(spec.remember); + return remembered ?? spec.initial; +} + +const formatNumber = (value: number, format: 'len' | 'int' | 'ang' = 'len'): string => { + if (format === 'int') return String(Math.round(value)); + if (format === 'ang') return String(Number(value.toFixed(4))); + return value.toFixed(4); // LUPREC 4 +}; + +function renderDefault(prompt: PromptSpec): string | null { + const spec = prompt.default; + if (!spec) return null; + if (spec.kind === 'enter') return spec.label; + if (spec.kind === 'option') { + return spec.label ?? prompt.options?.find((option) => option.key === spec.key)?.label ?? null; + } + const value = defaultValue(spec); + if (value === null) return null; + if (isXY(value)) return `${value.x.toFixed(4)}, ${value.y.toFixed(4)}`; + return formatNumber(value, spec.format); +} + +/** 「다음 점 지정 또는 [닫기(C)/명령 취소(U)] <10.0000>:」 · 키워드 물음은 「또는」 없이 「옵션 입력 [..] :」 */ +export function renderPrompt(prompt: PromptSpec, kind?: StepKind): string { + const shown = (prompt.options ?? []).filter((option) => !option.hidden); + const joiner = kind === 'keyword' ? ' ' : ' 또는 '; + const options = shown.length + ? `${joiner}[${shown.map((option) => `${option.label}(${option.kw})`).join('/')}]` + : ''; + const fallback = renderDefault(prompt); + return `${prompt.text}${options}${fallback === null ? '' : ` <${fallback}>`}:`; +} + +// ---- 옵션 맞추기(설계 2-4) ---- + +/** 친 글 → 맞추기 꼴 — 빈칸 뗌 · 대문자 · 앞 `_` 하나 뗌 */ +export const normalizeKeyword = (text: string): string => text.trim().toUpperCase().replace(/^_/, ''); + +const matches = (text: string, option: OptionSpec): boolean => + text === option.kw || text === option.en || (option.en.startsWith(text) && text.startsWith(option.kw)); + +export type OptionMatch = { option: OptionSpec } | { absent: OptionSpec } | null; + +/** 친 글이 맞는 옵션 — kw 딱 맞음 우선 · 숨김도 · 없는 옵션(absent)은 안내용 */ +export function matchOption(text: string, prompt: PromptSpec | null | undefined): OptionMatch { + if (!prompt) return null; + const typed = normalizeKeyword(text); + if (!typed) return null; + const pick = (list: OptionSpec[] | undefined) => { + const hits = (list ?? []).filter((option) => matches(typed, option)); + return hits.find((option) => option.kw === typed) ?? hits[0]; + }; + const option = pick(prompt.options); + if (option) return { option }; + const absent = pick(prompt.absent); + return absent ? { absent } : null; +} + +// ---- 공용 옵션 ---- + +const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({ + key, + kw, + en, + label, +}); +const hidden = (option: OptionSpec): OptionSpec => ({ ...option, hidden: true }); + +/** 여러 명령이 같이 쓰는 옵션 — 글자가 다른 명령(종료(X) 등)은 그 도구가 따로 만듦 */ +export const OPT = { + UNDO: opt('UNDO', 'U', '명령 취소'), + CLOSE: opt('CLOSE', 'C', '닫기'), + EXIT: opt('EXIT', 'E', '종료'), + MULTIPLE: opt('MULTIPLE', 'M', '다중'), + YES: opt('YES', 'Y', '예'), + NO: opt('NO', 'N', '아니오'), + DISPLACEMENT: opt('DISPLACEMENT', 'D', '변위'), + COPY: opt('COPY', 'C', '복사'), + REFERENCE: opt('REFERENCE', 'R', '참조'), + TEXT: opt('TEXT', 'T', '문자'), + ALL: hidden(opt('ALL', 'ALL', '전체')), + LAST: hidden(opt('LAST', 'L', '최종')), + PREVIOUS: hidden(opt('PREVIOUS', 'P', '이전')), +} as const; + +/** 고르기 물음 공용(설계 2-15) — 숨은 옵션 ALL · L · P 의 처리는 선택 도구 몫 */ +export const SELECT_OBJECTS: PromptSpec = { + text: '객체 선택', + options: [OPT.ALL, OPT.LAST, OPT.PREVIOUS], +}; diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index f6974dcb2..c64008db0 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -23,6 +23,8 @@ import { } from './commands.utility'; import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify'; import { VIEW_COMMANDS } from './commands.view'; +import { inputPrefs } from '../../../../A00_Common/input_prefs'; +import '../tools/factories/end-command'; // 명령 끝 → 쉬는 상태 갈고리 const ALL_COMMANDS: CadCommand[] = [ ...DRAW_COMMANDS, @@ -112,11 +114,23 @@ export const TOOL_STATE_MACHINES = Object.fromEntries( ]) ) as Record; -/** 명령행 입력 해석 — 정확한 이름·별칭이 먼저, 없으면 접두사 후보의 첫 항목 */ +/** 내 별칭(☰ [설정] · `cadAliases`) 이 가리키는 명령들 — 부를 때마다 읽어 저장 즉시 따름 */ +function userAliasCommands(match: (alias: string) => boolean): CadCommand[] { + return Object.entries(inputPrefs().cadAliases).flatMap(([id, aliases]) => { + const command = COMMANDS_BY_ID.get(id); + return command && aliases.some(match) ? [command] : []; + }); +} + +/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식) 딱 맞음만(앞글자 실행 없음 · 후보는 목록) */ export function resolveCommandInput(input: string): CadCommand | undefined { - const text = input.trim().toUpperCase(); + const text = input.trim().toUpperCase().replace(/^_/, ''); if (!text) return undefined; - return COMMANDS_BY_ID.get(text) ?? COMMANDS_BY_ALIAS.get(text) ?? matchCommandPrefixes(text)[0]; + return ( + COMMANDS_BY_ID.get(text) ?? + userAliasCommands((alias) => alias === text)[0] ?? + COMMANDS_BY_ALIAS.get(text) + ); } /** 접두사가 같은 명령 후보 (커서 옆 자동완성 목록에 쓴다) */ @@ -127,7 +141,8 @@ export function matchCommandPrefixes(input: string): CadCommand[] { (command.aliases ?? []).some((alias) => alias.startsWith(text)) ); const byId = ALL_COMMANDS.filter((command) => command.id.startsWith(text)); - return [...new Set([...byAlias, ...byId])]; + const byUser = userAliasCommands((alias) => alias.startsWith(text)); + return [...new Set([...byUser, ...byAlias, ...byId])]; } /** 명령행 자동완성에 보여줄 문자열 (예: "LINE (L) 선") */ diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 3c905bb6b..41bebbe94 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -2,6 +2,8 @@ import { toast } from 'react-toastify'; import { Actor } from 'xstate'; import { HtmlEvent } from '../App.types'; +import { releaseOnePoint } from '../inputController/input-angle-lock'; +import { cancelActiveCommand } from '../inputController/prompt-input'; import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state'; import type { CadCommand } from './command.types'; import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry'; @@ -14,7 +16,8 @@ const commandHistory: string[] = []; export const getLastCommandId = (): string | null => lastCommandId; export const getCommandHistory = (): string[] => commandHistory; -function log(line: string) { +/** 기록 줄에 한 줄(명령 · 거절 · 알림) */ +export function logLine(line: string) { commandHistory.push(line); if (commandHistory.length > COMMAND_HISTORY_LIMIT) { commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT); @@ -28,27 +31,29 @@ export function runCommand(command: CadCommand): string { // 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정). if (isDrawingReadOnly() && !isViewOnlyCommand(command)) { toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`); - log(`${command.id}: 확정 도면 — 실행하지 않음`); + logLine(`${command.id}: 확정 도면 — 실행하지 않음`); return ''; } if (command.needsSelection && getSelectedEntities().length === 0) { toast.info(`${command.label}: 객체를 먼저 선택하십시오.`); - log(`${command.id}: 선택 없음`); + logLine(`${command.id}: 선택 없음`); return ''; } lastCommandId = command.id; + releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음 if (command.machine) { + cancelActiveCommand(); // 앞 명령을 끊고 켬(오토캐드 ^C^C) · 즉시형은 도구를 둠 setActiveToolActor(new Actor(command.machine)); // 도구를 켠 뒤 준비 동작이 필요한 명령(파일 선택 등)은 run()도 함께 부른다 command.run?.(); - log(`${command.id} ${command.label}`); + logLine(`${command.id} ${command.label}`); return command.id; } const result = command.run?.(); - log(`${command.id} ${typeof result === 'string' ? result : command.label}`); + logLine(`${command.id} ${typeof result === 'string' ? result : command.label}`); return typeof result === 'string' ? result : command.id; } diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 9d354a25a..5d878f70d 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -3,16 +3,20 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react'; import { describeCommand, matchCommandPrefixes } from '../commands/registry'; import { getCommandHistory, runCommandInput } from '../commands/run-command'; import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink'; -import { getInputController, getLastStateInstructions } from '../state'; +import { commandCandidate, recallInput } from '../inputController/input-history'; +import { currentPrompt } from '../inputController/prompt-input'; +import { getInputController } from '../state'; export const CommandLine: FC = () => { const [text, setText] = useState(''); - const [historyIndex, setHistoryIndex] = useState(null); + /** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */ + const [pick, setPick] = useState<{ base: string; index: number } | null>(null); + const [fromHistory, setFromHistory] = useState(false); const inputRef = useRef(null); const history = getCommandHistory(); - const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.'; + const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.'; - const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]); + const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]); const submit = (event: FormEvent) => { event.preventDefault(); @@ -25,17 +29,28 @@ export const CommandLine: FC = () => { // 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다 getInputController().submitText(value); setText(''); - setHistoryIndex(null); + setPick(null); + setFromHistory(false); }; - const recallHistory = (direction: -1 | 1) => { - if (!history.length) return; - const nextIndex = - historyIndex === null - ? history.length - 1 - : Math.min(history.length - 1, Math.max(0, historyIndex + direction)); - setHistoryIndex(nextIndex); - setText(history[nextIndex].split(' ')[0]); + /** 후보 보이면 후보 돌림 · 아니면 친 명령 · 최근 값 기록(캔버스와 같은 함수 · 알림 줄 안 섞음) */ + const recall = (direction: -1 | 1) => { + if (pick || (!fromHistory && text.trim() && suggestions.length)) { + cycle(direction); + return; + } + const value = recallInput(direction, text); + if (value === null) return; + setText(value); + setFromHistory(true); + }; + const cycle = (direction: -1 | 1) => { + const base = pick?.base ?? text; + const index = pick ? pick.index + direction : direction > 0 ? 0 : -1; + const name = commandCandidate(base, index); + if (!name) return; + setPick({ base, index }); + setText(name); }; return ( @@ -52,7 +67,11 @@ export const CommandLine: FC = () => { id="cad-command" ref={inputRef} value={text} - onChange={(event) => setText(event.target.value)} + onChange={(event) => { + setText(event.target.value); + setPick(null); + setFromHistory(false); + }} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 if (event.key === ' ' && !activeToolWantsFreeText()) { @@ -67,17 +86,13 @@ export const CommandLine: FC = () => { event.preventDefault(); getInputController().handleEscapeKey(); } - if (event.key === 'ArrowUp') { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); - recallHistory(-1); - } - if (event.key === 'ArrowDown') { - event.preventDefault(); - recallHistory(1); + recall(event.key === 'ArrowUp' ? -1 : 1); } if (event.key === 'Tab' && suggestions.length) { event.preventDefault(); - setText(suggestions[0].id); + cycle(1); } }} placeholder="명령 입력 (예: L, LINE, REC, MOVE)" diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx index c5c16e0c2..679be9f7b 100644 --- a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -1,6 +1,8 @@ /** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */ import type { FC } from 'react'; import { runCommandInput } from '../commands/run-command'; +import { redoCommand, undoCommand } from '../inputController/prompt-input'; +import { zoomExtents } from '../inputController/zoom-prompt'; import { getScreenCanvasDrawController, isDrawingReadOnly, @@ -17,10 +19,10 @@ export const ViewControls: FC = () => {
{showUndo && ( <> - - @@ -28,7 +30,7 @@ export const ViewControls: FC = () => {