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

This commit is contained in:
2026-10-15 19:40:05 +09:00
150 changed files with 11354 additions and 497 deletions
+53
View File
@@ -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;
}
+159
View File
@@ -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<typeof L>[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<TabId, HTMLElement> = {
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));
},
});
}
+118
View File
@@ -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<AliasError, Parameters<typeof L>[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<string, string[]>,
commands: CadCommandRow[] = CAD_COMMANDS,
): Record<string, AliasError> {
const ids = new Set(commands.map((c) => c.id));
const owner = new Map<string, string>();
const errors: Record<string, AliasError> = {};
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<string, string[]>): {
root: HTMLElement;
mark: (errors: Record<string, AliasError>) => void;
} {
const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } });
const inputs = new Map<string, HTMLInputElement>();
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<string, AliasError>): 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 };
}
+19 -5
View File
@@ -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);
+2
View File
@@ -103,6 +103,8 @@ export const STATE_REGISTRY = {
"cross-columns": { bucket: "pref", scope: "global" },
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
/** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */
"input-prefs": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
"section-panel-height": {
+4 -2
View File
@@ -14,6 +14,8 @@
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */
import { wheelEventIn } from "./input_prefs";
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
@@ -213,8 +215,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
// ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐.
zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy);
},
{ passive: false },
);
+29
View File
@@ -55,6 +55,35 @@
font-size: var(--btn-bar-font-size);
}
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */
.b05-subpanel__popout {
flex: none;
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
}
.b05-profile__masshaul-overlay > .b05-subpanel__popout {
position: absolute;
top: 4px;
left: var(--spacing-8);
z-index: 4;
}
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
.b05-profile__masshaul-overlay.is-popout,
.b05-drainage.is-popout {
position: relative;
width: 100%;
max-width: none;
height: 100dvh;
}
.b05-profile__masshaul-overlay.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-workflow-overlay__handle {
display: none;
}
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
.b05-route__panel .b06-adjust-dock,
+106 -23
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* A00_Common/design/design_profile_popout.ts
* 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35).
* 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름.
*
* 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다.
* 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난
@@ -13,28 +14,39 @@ import type { DesignProfile } from "./design_profile";
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
button: HTMLButtonElement,
): void {
const { root } = profile;
/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */
const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window;
/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */
interface PopoutSpec {
piece: HTMLElement;
button: HTMLButtonElement;
name: string;
features: string;
docTitle: string;
/** 조각을 제자리로 돌림. */
home: () => void;
/** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */
onMove?: (popped: boolean) => void;
}
/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */
function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
const { piece, button } = spec;
let popup: Window | null = null;
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.textContent = "창 분할";
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
const restore = (): void => {
if (!root.classList.contains("is-popout")) return;
root.classList.remove("is-popout");
if (!piece.classList.contains("is-popout")) return;
piece.classList.remove("is-popout");
button.textContent = "창 분할";
slot.append(root);
spec.home();
popup = null;
requestAnimationFrame(profile.redraw);
spec.onMove?.(false);
viewOfNode(piece).requestAnimationFrame(profile.redraw);
};
const closePopup = (): void => {
popup?.close();
@@ -49,28 +61,31 @@ export function attachProfilePopout(
};
function open(): void {
const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720");
const opener = viewOfNode(button);
const next = opener.open("", spec.name, spec.features);
if (!next) {
showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error");
return;
}
popup = next;
const source = opener.document;
const doc = next.document;
doc.title = "종단 — Aislo";
// 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
doc.title = spec.docTitle;
// 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
doc.head.replaceChildren(
...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
doc.importNode(node, true),
),
);
Array.from(document.documentElement.attributes).forEach((attr) =>
Array.from(source.documentElement.attributes).forEach((attr) =>
doc.documentElement.setAttribute(attr.name, attr.value),
);
doc.body.className = document.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
root.classList.add("is-popout");
doc.body.className = source.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`;
piece.classList.add("is-popout");
button.textContent = "창 결합";
doc.body.replaceChildren(root);
doc.body.replaceChildren(piece);
spec.onMove?.(true);
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
FORWARD_TYPES.forEach((type) =>
@@ -93,7 +108,75 @@ export function attachProfilePopout(
new (next as Window & typeof globalThis).ResizeObserver(() => {
next.cancelAnimationFrame(pending);
pending = next.requestAnimationFrame(profile.redraw);
}).observe(root);
}).observe(piece);
next.addEventListener("pagehide", restore);
}
}
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
button: HTMLButtonElement,
): void {
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
attachPopout(profile, {
piece: profile.root,
button,
name: "aislo-profile-popout",
features: "popup,width=1400,height=720",
docTitle: "종단 — Aislo",
home: () => slot.append(profile.root),
});
}
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
export function attachSubPanelPopouts(profile: DesignProfile): void {
const subButton = (title: string): HTMLButtonElement => {
const button = document.createElement("button");
button.className = "ui-btn ui-btn--ghost b05-subpanel__popout";
button.title = title;
return button;
};
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
if (massOverlay && massHandle) {
const button = subButton("유토곡선 창 분할");
massOverlay.append(button);
attachPopout(profile, {
piece: massOverlay,
button,
name: "aislo-masshaul-popout",
features: "popup,width=1400,height=420",
docTitle: "유토곡선 — Aislo",
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
home: () => massHandle.before(massOverlay),
onMove: (popped) => {
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
massHandle.hidden = popped;
massOverlay.style.height = "";
},
});
}
const drainage = profile.side;
const header = drainage?.root.querySelector<HTMLElement>(".b05-drainage__header");
if (drainage && header) {
const button = subButton("배수유역 창 분할");
header.prepend(button);
const content = drainage.root.parentElement;
attachPopout(profile, {
piece: drainage.root,
button,
name: "aislo-drainage-popout",
features: "popup,width=1000,height=720",
docTitle: "배수유역 — Aislo",
// 제자리 = 종단 content 끝(종단 본문 오른쪽).
home: () => content?.append(drainage.root),
// 지도 크기 관찰을 놓인 창 것으로(325-1).
onMove: () => drainage.rehome(),
});
}
}
+2 -1
View File
@@ -24,7 +24,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U
import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout } from "./design_profile_popout";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -179,6 +179,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
});
shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, popoutButton);
attachSubPanelPopouts(profile);
shared.profile = profile;
return profile;
}
+64
View File
@@ -0,0 +1,64 @@
/* =============================================================================
* input_prefs.ts
* 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀.
*
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
* - 휠 확대 방향 하나로 모든 화면 일체 — 당김 = 휠을 몸 쪽으로(deltaY > 0) 굴리면 확대
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
* ========================================================================== */
export type WheelZoom = "pull" | "push";
export interface InputPrefs {
/** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */
wheelZoom: WheelZoom;
/** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */
cadAliases: Record<string, string[]>;
}
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "pull", cadAliases: {} };
const cleanAliases = (raw: unknown): Record<string, string[]> => {
const out: Record<string, string[]> = {};
if (!raw || typeof raw !== "object") return out;
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
const aliases = list
.filter((a): a is string => typeof a === "string")
.map((a) => a.toUpperCase());
if (aliases.length) out[id.toUpperCase()] = aliases;
}
return out;
};
/** 지금 설정 — 부를 때마다 저장소에서 읽음 */
export function inputPrefs(): InputPrefs {
try {
const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY);
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
return {
wheelZoom: value?.wheelZoom === "push" ? "push" : "pull",
cadAliases: cleanAliases(value?.cadAliases),
};
} catch {
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
}
}
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
export const wheelIn = (deltaY: number): boolean =>
inputPrefs().wheelZoom === "push" ? deltaY < 0 : deltaY > 0;
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
export const isTrackpadPinch = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
e.ctrlKey && Math.abs(e.deltaY) < 50;
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
+98
View File
@@ -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<string, string>;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
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<void> {
const guard = link.route === currentRoute() ? takeLeaveGuard() : null;
if (guard && !(await guard())) return;
navigateTo(link.route, link.query);
}
+16 -1
View File
@@ -28,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar";
@@ -90,6 +90,9 @@ export interface SpreadsheetHandle {
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
select(범위: string | string[], 시트?: string): boolean;
destroy(): void;
}
@@ -350,6 +353,18 @@ export function createSpreadsheet(
return 이름;
},
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
select(range, sheetKey) {
const target = sheetKey
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
: ctx.sheet();
const next = target && selectRanges(target, typeof range === "string" ? [range] : range);
if (!target || !next) return false;
showSheet(target.id);
if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘
select(next, true);
grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게
return true;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
+3 -1
View File
@@ -13,6 +13,7 @@
import "./spreadsheet_grid.css";
import { colIndex, toA1 } from "./spreadsheet_address";
import { wheelZoomsIn } from "../touch_bridge";
import {
buildColGeometry,
buildOutlineRuns,
@@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
if (!ev.ctrlKey) return;
ev.preventDefault();
const before = zoom;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332)
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step)));
if (next === before) return;
zoom = next;
colGeo = scaledGeometry(baseColGeo, zoom);
@@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress):
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
}
/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */
export function selectRanges(sheet: Sheet, texts: string[]): Selection | null {
const list = merges(sheet);
const 범위: CellRange[] = [];
for (const t of texts) {
const g = parseRange(t);
if (!g) return null;
범위.push(expand(g, list));
}
if (!범위.length) return null;
const at = { r: 범위[0].r0, c: 범위[0].c0 };
return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } };
}
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
const r0 = Math.min(a, b);
+4 -3
View File
@@ -11,6 +11,8 @@
* 마우스 · 펜 이벤트는 건드리지 않음.
* ========================================================================== */
import { wheelEventIn } from "./input_prefs";
const LONG_MS = 500;
const SLOP_PX = 10;
const DOUBLE_MS = 300;
@@ -31,9 +33,8 @@ let contactPx = 0;
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */
export const wheelZoomsIn = (e: WheelEvent): boolean =>
isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0;
/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */
export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e));
const ownsTouch = (t: EventTarget | null): boolean =>
t instanceof Element && !!t.closest("[data-touch-own]");
+43 -32
View File
@@ -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<string, string> =
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<typeof L>[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",
@@ -350,6 +350,18 @@ async def run_auto_design_chain(
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
try:
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
_schedule_background_task(
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
)
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
return {
"route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0),
+3 -2
View File
@@ -1,6 +1,7 @@
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch";
import { wheelEventIn } from "../A00_Common/input_prefs";
export const SURFACE_CAMERA_FOV = 50;
const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9;
@@ -246,11 +247,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */
* 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */
function onWheel(event: WheelEvent): void {
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP);
}
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
@@ -211,6 +211,8 @@ export interface StationTickOptions {
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
onLabel?: (box: StationLabelBox) => void;
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
chainages?: ReadonlyArray<number>;
}
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
@@ -253,7 +255,10 @@ export function drawStationTicks(
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) {
const marks: number[] = [];
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
for (const chainage of marks) {
const stationNo = Math.round(chainage / interval);
if (stationNo % step !== 0) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
+4
View File
@@ -100,6 +100,8 @@ export interface RoutePlanResponse {
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
edited: boolean;
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
station_snap?: boolean;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
@@ -220,6 +222,8 @@ export interface CrossPreviewRequest {
station_interval_m: number;
/** 횡단 반폭(m) — 없으면 서버 기본값. */
cross_half_width_m?: number;
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
station_snap?: boolean;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
+20 -6
View File
@@ -3,8 +3,9 @@
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
공사비 어림 최소 · 비중.
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로
`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로
기존 `POST /route/replan`([확인])을 부른다.
갈래(`mode`):
· follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선.
@@ -182,6 +183,20 @@ def write_initial_choice(
return choice
def resolve_inputs(
max_grade: float,
stream_offset_m: float | None,
grade_smooth_m: float | None,
target_grade: float | None,
) -> tuple[float, float, float]:
"""(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이."""
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
return stream_offset, grade_smooth, goal_grade
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
if mode != "weighted":
@@ -240,10 +255,9 @@ def generate_initial_route(
if len(expected) < 2:
raise ValueError("예상노선 정점이 2개 미만입니다.")
used = resolve_weights(mode, weights)
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
stream_offset, grade_smooth, goal_grade = resolve_inputs(
max_grade, stream_offset_m, grade_smooth_m, target_grade
)
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
@@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
NoRoute,
RouteGraph,
)
from common_util.common_util_route_geometry import densify_route
from common_util.common_util_route_polyline import build_planned_polyline
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
@@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05
SIMILAR_M = 10.0
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
CONSTRAINT_SHARE = 1.0
# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼
TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
@@ -103,14 +107,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]
@@ -1,8 +1,9 @@
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을
바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을
바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙
(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
@@ -0,0 +1,110 @@
"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」).
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침.
1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
from __future__ import annotations
import math
import numpy as np
from shapely.geometry import LineString, Point
from common_util.common_util_route_polyline import (
PlannedPolyline,
_inner_angle_deg,
build_planned_polyline,
)
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
Xy = tuple[float, float]
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
"""곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴."""
starts = {curve.node_first: curve for curve in outline.curves}
inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)}
points: list[Xy] = []
flags: list[bool] = []
radii: list[float | None] = []
for index, node in enumerate(outline.nodes):
if index in inside:
continue
curve = starts.get(index)
points.append(curve.apex if curve else (node.x, node.y))
flags.append(curve is not None)
radii.append(round(curve.radius_m, 2) if curve else None)
return points, flags, radii
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray:
"""R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m)."""
a = np.asarray(apex, dtype=float)
p = samples - a # (S, 2) 교각점 기준
units = [(np.asarray(end, dtype=float) - a) for end in (before, after)]
halves = [float(np.hypot(*u)) / 2 for u in units]
units = [u / (2 * h) for u, h in zip(units, halves)]
inner = math.radians(_inner_angle_deg(before, apex, after))
tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1)
legs = []
for unit, half in zip(units, halves):
along = p @ unit
side = np.abs(_cross(unit, p))
legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half))))
bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1]))
center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2)
starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점
q = p[None] - center # (M, S, 2) 중심 → 점
turn = _cross(starts[0], starts[1])
within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0)
arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf)
return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1)
def widen(
outline: PlannedPolyline,
path: list[Xy],
min_radius_m: float,
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
) -> PlannedPolyline:
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
if not outline.curves:
return outline
points, flags, radii = _flat(outline)
line = LineString(path)
coords = np.asarray(path, dtype=float)
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
for index in range(1, len(points) - 1):
if not flags[index]:
continue
before, apex, after = points[index - 1], points[index], points[index + 1]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
if half_tan <= 1e-9:
continue
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터
# 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼
ends = [
line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2))
for end in (before, after)
]
samples = coords[(along >= min(ends)) & (along <= max(ends))]
if not candidates.size or not len(samples):
continue
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
if ok.size:
radii[index] = float(ok[0])
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
@@ -0,0 +1,128 @@
"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3).
같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음).
층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리
`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncIterator
from B05_Profile.B05_Profile_Engine_RouteInitial import (
DEFAULT_WEIGHTS,
Terrain,
_pool_key,
resolve_inputs,
resolve_weights,
)
from common_util.common_util_json import atomic_write_json
ALGO_VERSION = 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
+19 -6
View File
@@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
"""
curves = _read_design_curve_table(project_root).get("curves")
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def load_station_snap(project_root: Path) -> bool:
"""[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔."""
return _read_design_curve_table(project_root).get("station_snap") is True
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
return {}
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
return {}
return data if isinstance(data, dict) else {}
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
@@ -435,6 +445,7 @@ def run_section_generation(
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
station_snap=load_station_snap(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -548,6 +559,8 @@ def sample_structure_sections(
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
station_snap=load_station_snap(project_root),
)
def structural(entry: dict[str, Any]) -> bool:
@@ -6,6 +6,9 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
`resources/tester/test_224_2_station_snap.py`.
"""
import math
@@ -275,6 +278,35 @@ def _design_curve_spans(
return sorted(spans)
#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m).
CURVE_STATION_EDGE_M = 0.005
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
"""
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
middle = (start + end) / 2.0
inside = [
index
for index, value in enumerate(values)
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
]
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = float(math.floor(middle + 0.5))
return np.unique(np.asarray(values, dtype=np.float64))
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
@@ -319,8 +351,12 @@ def generate_sections(
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
"""
options = options or SectionGenerationOptions()
options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
@@ -331,6 +367,9 @@ def generate_sections(
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
@@ -372,7 +411,6 @@ def generate_sections(
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
+33 -83
View File
@@ -26,7 +26,7 @@
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀)
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
"""
import asyncio
@@ -41,10 +41,17 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import AliasChoices, BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
RouteInitialRequest,
RouteInitialWeights,
RouteReplanRequest,
RouteVertexInput,
)
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
densify_route,
@@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
pool = get_db_pool()
@@ -305,9 +244,12 @@ def _write_planned_polyline(
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
@@ -321,13 +263,13 @@ def _write_planned_polyline(
)
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text(
json.dumps(
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
ensure_ascii=False,
),
encoding="utf-8",
)
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
}
if station_snap is not None:
table["station_snap"] = bool(station_snap)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
@@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
}
@@ -604,11 +547,17 @@ async def replan_route(
radii = [vertex.radius_m for vertex in request.vertices]
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
working_path = planned_route_working_path(project_root)
previous = working_path.read_bytes() if working_path.is_file() else None
keep = (working_path, _curves_path(working_path), _nodes_path(working_path))
previous = {path: path.read_bytes() if path.is_file() else None for path in keep}
summary = await asyncio.to_thread(
functools.partial(
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii
_write_planned_polyline,
simplify=False,
curve_flags=curve_flags,
radii=radii,
station_snap=request.station_snap,
),
working_path,
nodes,
@@ -633,10 +582,11 @@ async def replan_route(
)
if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
if previous is None:
working_path.unlink(missing_ok=True)
else:
working_path.write_bytes(previous)
for path, saved in previous.items():
if saved is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(saved)
logger.warning(
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
project_id,
+87 -23
View File
@@ -30,14 +30,35 @@ async def initial_route(
) -> dict[str, Any] | JSONResponse:
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
**파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]).
작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로
`POST /route/replan`([확인]).
"""
return await compute_initial(project_id, request)
async def compute_initial(
project_id: UUID, request: replan.RouteInitialRequest
) -> dict[str, Any] | JSONResponse:
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
"""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import (
generate_initial_route,
load_terrain_and_streams,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
cover_shortfall,
project_pipes,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
is_default,
pool_lock,
read_stored,
request_fingerprint,
write_stored,
)
from common_util.common_util_surface_confirmation import (
analysis_terrain_params,
get_surface_confirmation_params,
@@ -81,37 +102,62 @@ async def initial_route(
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
selection = analysis_terrain_params(selection, request.terrain)
inputs = {
"mode": request.mode,
"weights": weights,
"max_grade": max_grade,
"min_radius_m": radius_m,
"stream_offset_m": stream_offset_m,
"grade_smooth_m": grade_smooth_m,
"target_grade": None if target_pct is None else target_pct / 100.0,
"design_speed": design_speed or 20,
"with_profile": request.with_profile,
}
started = time.perf_counter()
try:
terrain, streams = await asyncio.to_thread(
load_terrain_and_streams, project_root, selection
)
pipes = (
await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None
)
result = await asyncio.to_thread(
functools.partial(
generate_initial_route,
[(x, y) for x, y in expected],
terrain,
streams,
mode=request.mode,
weights=weights,
max_grade=max_grade,
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
target_grade=None if target_pct is None else target_pct / 100.0,
design_speed=design_speed or 20,
pipes=pipes,
with_profile=request.with_profile,
)
route = [(x, y) for x, y in expected]
key, pool_key = await asyncio.to_thread(
functools.partial(request_fingerprint, route, terrain, streams, **inputs)
)
async with pool_lock(pool_key):
stored = await asyncio.to_thread(read_stored, project_root, key)
if stored is not None:
result = stored["result"]
else:
# 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다.
result = await asyncio.to_thread(
functools.partial(
generate_initial_route, route, terrain, streams, pipes=None, **inputs
)
)
await asyncio.to_thread(
write_stored,
project_root,
key,
pool_key,
request.model_dump(),
result,
is_default(
request.mode,
weights,
criteria.model_dump() if criteria else None,
request.terrain,
),
)
profile = result["metrics"].get("profile")
if request.with_profile and profile and "error" not in profile:
pipes = await asyncio.to_thread(project_pipes, project_root)
if pipes:
profile.update(cover_shortfall(profile, result["planned"], pipes))
except (FileNotFoundError, ValueError) as error:
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
logger.info(
"초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
"초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
request.mode,
"저장본" if stored is not None else "셈",
project_id,
time.perf_counter() - started,
result["metrics"]["length_m"],
@@ -122,10 +168,28 @@ async def initial_route(
"status": "success",
"project_id": str(project_id),
**result,
"stored": stored is not None,
"current_initial": current,
}
async def precompute_defaults(project_id: UUID) -> None:
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
started = time.perf_counter()
for mode in DEFAULT_MODES:
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
outcome = await compute_initial(project_id, request)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
)
return
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
import json
+10 -1
View File
@@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel):
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
cross_half_width_m: float | None = Field(None, gt=0)
terrain: AnalysisTerrain | None = None
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
station_snap: bool = False
def _cross_preview(
@@ -177,7 +179,14 @@ def _cross_preview(
else defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
)
result = generate_sections(built.vertices, sampler, options)
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
result = generate_sections(
built.vertices,
sampler,
options,
design_curves=[curve.as_dict() for curve in built.curves],
station_snap=request.station_snap,
)
sections = result["cross_sections"]
if not sections:
return None
+74 -1
View File
@@ -2,7 +2,7 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
from config.config_system import (
@@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel):
route_points: list[dict[str, Any]] = Field(default_factory=list)
surface_params: dict[str, Any]
route_params: dict[str, Any] | None = None
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
+2 -2
View File
@@ -536,8 +536,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
selectBasin: (index) => selectBasin(index),
});
// 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면
// 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시).
// 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다(늘릴 때마다 확대되면 보던 자리를 잃음).
const resizeObserver = observeViewportSize(viewport, {
meta: () => meta,
anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }),
@@ -696,5 +695,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
resizeObserver.disconnect();
widthResizer.dispose();
},
rehome: () => resizeObserver.rehome(),
};
}
@@ -52,6 +52,8 @@ export interface DrainagePanel {
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
* 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */
setIntervalSpans: (spans: ReadonlyArray<RouteSpanBand>) => void;
/** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */
rehome: () => void;
dispose: () => void;
}
+21 -6
View File
@@ -23,6 +23,7 @@ import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { viewOf } from "../common_util/common_util_view_window";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -454,7 +455,8 @@ export function preserveViewOnResize(
};
}
/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */
/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. `rehome` = 판이 다른 창으로 옮겨 간 뒤 그 창의
* 관찰자로 바꿔 낌(직전 크기를 지켜 배율 유지 · 325-1) · `disconnect` = 정리. */
export function observeViewportSize(
viewport: HTMLElement,
ports: {
@@ -463,9 +465,9 @@ export function observeViewportSize(
apply: (next: { scale: number; offsetX: number; offsetY: number }) => void;
redraw: () => void;
},
): ResizeObserver {
): { rehome: () => void; disconnect: () => void } {
let last: { width: number; height: number } | null = null;
const observer = new ResizeObserver(() => {
const onResize = (): void => {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
@@ -482,9 +484,22 @@ export function observeViewportSize(
}
last = { width, height };
ports.redraw();
});
observer.observe(viewport);
return observer;
};
// 새 창 크기 변화는 그 창의 관찰자만 안다 — 판이 놓인 창의 것으로 단다.
const watch = (): ResizeObserver => {
const view = viewOf(viewport) as Window & typeof globalThis;
const next = new view.ResizeObserver(onResize);
next.observe(viewport);
return next;
};
let observer = watch();
return {
rehome: () => {
observer.disconnect();
observer = watch();
},
disconnect: () => observer.disconnect(),
};
}
/** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */
+5 -3
View File
@@ -133,6 +133,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
};
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay);
const heightResizer = createPanelResizer({
axis: "vertical",
target: root,
@@ -143,7 +145,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
min: () => {
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
return Math.max(
MIN_PANEL_HEIGHT,
@@ -169,7 +171,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
heightResizer.root.addEventListener("pointerdown", () => {
mainPanelDragging = true;
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
const massH = massDocked() ? massHaul.overlay.offsetHeight : 0;
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
mainDragRef = {
chart: Math.max(
@@ -191,7 +193,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
@@ -67,7 +67,8 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
const massOpen = !deps.massHaul.overlay.hidden;
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay);
const tableOpen = deps.tableOverlay.isOpen();
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
@@ -438,7 +438,12 @@ export function createRouteMassHaulPanel(
window: undefined,
hideStations: true,
}
: { ...params.axis, window: windowState };
: {
...params.axis,
// 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2).
viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange,
window: windowState,
};
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const chart = createMassHaulChart(
+36 -2
View File
@@ -59,6 +59,7 @@ import {
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import { curveSpans, snapCurveStations, stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame";
import {
lockColumn,
readRouteLocks,
@@ -109,7 +110,7 @@ export async function openRouteEditModal(
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome();
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, terrainSelect } = chrome;
const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -138,6 +139,24 @@ export async function openRouteEditModal(
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */
let stationSnap = false;
let savedStationSnap = false;
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
* 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */
const stations = (): number[] | null => {
if (!stationSnap) return null;
const line = plannedLine.length ? plannedLine : planned;
return snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curveInfo),
);
};
const setStationSnap = (on: boolean): void => {
stationSnap = on;
stationSnapButton.classList.toggle("is-active", on);
stationSnapButton.setAttribute("aria-pressed", String(on));
};
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
@@ -165,6 +184,7 @@ export async function openRouteEditModal(
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
station_snap: stationSnap,
}),
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
});
@@ -217,6 +237,11 @@ export async function openRouteEditModal(
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
stationSnapButton.addEventListener("click", () => {
setStationSnap(!stationSnap);
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
draw();
});
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
let viewReady = false;
const viewMemory = createViewMemory(projectId, () =>
@@ -307,6 +332,7 @@ export async function openRouteEditModal(
toScreen,
uprightRad: rotation.uprightRad(),
dragging: pointer.dragging(),
chainages: stations(),
}),
measure: measure.marks(),
crossWidth: crossPreview.watched(),
@@ -319,6 +345,7 @@ export async function openRouteEditModal(
locked: apexLock,
picked,
stationIntervalM,
stations: stations(),
});
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
@@ -448,6 +475,7 @@ export async function openRouteEditModal(
canvas,
status,
stationIntervalM,
stations,
state: () => ({
planned,
plannedLine,
@@ -530,8 +558,12 @@ export async function openRouteEditModal(
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain,
changed: () =>
(history?.isDirty() ?? false) ||
pickedTerrain() !== designTerrain ||
stationSnap !== savedStationSnap,
terrain: pickedTerrain,
stationSnap: () => stationSnap,
initial,
profileShown: strip.settle,
close,
@@ -554,6 +586,8 @@ export async function openRouteEditModal(
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
savedStationSnap = plan.station_snap === true;
setStationSnap(savedStationSnap);
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
@@ -27,6 +27,8 @@ export interface RouteApplyParams {
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
stationSnap: () => boolean;
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
initial: {
current: () => unknown;
@@ -97,6 +99,7 @@ export function bindRouteApply(params: RouteApplyParams): void {
// 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다.
balanced_profile: await params.profileShown(),
terrain: params.terrain(),
station_snap: params.stationSnap(),
},
),
);
@@ -36,6 +36,8 @@ export interface RouteEditChrome {
measureText: HTMLElement;
measureButton: HTMLButtonElement;
minorContourButton: HTMLButtonElement;
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
stationSnapButton: HTMLButtonElement;
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
terrainSelect: HTMLSelectElement;
}
@@ -65,6 +67,9 @@ export function createRouteEditChrome(): RouteEditChrome {
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
aria-pressed="false"
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [확인] 때 저장">측점 스냅</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
@@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome {
'[data-act="contour-minor"]',
)!;
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
return {
overlay,
@@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome {
measureText,
measureButton,
minorContourButton,
stationSnapButton,
terrainSelect,
};
}
@@ -35,6 +35,7 @@ export interface CrossPreviewParams {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
station_snap?: boolean;
};
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
onShown?: () => void;
@@ -5,6 +5,10 @@
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at`
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
*
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations`
* ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`.
* ========================================================================== */
type Vertex = [number, number];
@@ -75,3 +79,98 @@ export function stationFrameAt(
const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] };
}
/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */
export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0;
/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */
export const CURVE_STATION_EDGE_M = 0.005;
/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */
export interface CurveEnds {
start?: ReadonlyArray<number> | null;
apex?: ReadonlyArray<number> | null;
end?: ReadonlyArray<number> | null;
radius_m?: number | null;
}
/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */
export function stationsAlong(
line: ReadonlyArray<Vertex>,
intervalM: number,
includeEndpoint: boolean,
): number[] {
const { chainage } = cleanPolyline(line);
const total = chainage.length ? chainage[chainage.length - 1] : 0;
const values: number[] = [];
if (intervalM > 0)
for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM);
if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0);
if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total);
return values;
}
/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */
function projectChainage(
points: Vertex[],
chainage: number[],
xy: ReadonlyArray<number>,
): [number, number] {
let bestGap = Infinity;
let bestAt = 0;
for (let index = 0; index < points.length - 1; index += 1) {
const [sx, sy] = points[index];
const vx = points[index + 1][0] - sx;
const vy = points[index + 1][1] - sy;
const length2 = vx * vx + vy * vy;
const safe = length2 > 1e-12 ? length2 : 1;
const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe));
const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]);
if (gap < bestGap) {
bestGap = gap;
bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio;
}
}
return [bestGap, bestAt];
}
/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림.
* `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */
export function curveSpans(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<CurveEnds>,
): Array<[number, number]> {
const { points, chainage } = cleanPolyline(line);
if (points.length < 2) return [];
const spans: Array<[number, number]> = [];
for (const { start, apex, end, radius_m } of curves) {
if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue;
const [startGap, startAt] = projectChainage(points, chainage, start);
const [endGap, endAt] = projectChainage(points, chainage, end);
if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue;
const cross =
(apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]);
if (Math.abs(cross) < 1e-12) continue;
spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]);
}
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 ·
* 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */
export function snapCurveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
): number[] {
const values = [...stations];
for (const [start, end] of spans) {
const middle = (start + end) / 2;
let nearest = -1;
values.forEach((value, index) => {
if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return;
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
nearest = index;
});
if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5);
}
return [...new Set(values)].sort((a, b) => a - b);
}
@@ -189,6 +189,8 @@ export interface InitialChoice {
similar?: SimilarRoute[] | null;
/** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */
bandWidthM?: number | null;
/** 서버 추천 저장본에서 꺼낸 결과인가(계획서 327) — 서버 `stored`. */
stored?: boolean;
/** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */
balance?: BalanceProfile | null;
}
@@ -336,6 +338,7 @@ export function normalizeRouteInitial(
terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
similar: parseSimilar(body.similar),
bandWidthM: num(body.band_width_m),
stored: body.stored === true,
balance: parseBalance(m.profile),
criteria: body.criteria
? {
@@ -321,13 +321,15 @@ export function stationAtScreen(
py: number,
maxPx: number,
labels: ReadonlyArray<StationLabelBox> = [],
/** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */
chainages: ReadonlyArray<number> | null = null,
): number | null {
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
const shown = labels.length
? new Set(labels.map((box) => Math.round(box.chainage / intervalM)))
: null;
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown);
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages);
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
return tick ?? stationLabelAtScreen(labels, px, py);
}
@@ -373,6 +375,7 @@ function stationTickAtScreen(
py: number,
maxPx: number,
shown: ReadonlySet<number> | null,
chainages: ReadonlyArray<number> | null,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
@@ -386,7 +389,10 @@ function stationTickAtScreen(
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
const marks: number[] = [];
if (chainages) marks.push(...chainages.filter((value) => value <= total));
else for (let chainage = 0; chainage <= total; chainage += intervalM) marks.push(chainage);
for (const chainage of marks) {
if (shown && !shown.has(Math.round(chainage / intervalM))) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
@@ -64,6 +64,8 @@ export interface RouteEditPointerParams {
canvas: HTMLCanvasElement;
status: HTMLElement;
stationIntervalM: number;
/** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
stations: () => ReadonlyArray<number> | null;
state: () => RouteEditPointerState;
setPicked: (index: number) => void;
setPickedContour: (index: number) => void;
@@ -194,6 +196,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
py,
pickPx(STATION_HIT_PX),
stationLabelBoxes(canvas),
params.stations(),
);
if (chainage === null) return false;
void params.crossPreview.open(chainage);
@@ -462,7 +462,9 @@ export function openRecommendModal(params: RecommendParams): void {
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${
choice.stored ? " (저장된 결과)" : ""
} — [적용]을 눌러야 편집 창에 놓입니다.`;
} catch (error) {
if (closed || mine !== seq) return;
stateEl.textContent = "셈하지 못했습니다.";
@@ -29,7 +29,7 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
follow: {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
],
@@ -94,6 +94,8 @@ export interface RouteEditScene {
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
stations: ReadonlyArray<number> | null;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<MeasureMark>;
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
@@ -478,6 +480,7 @@ function drawStationMarks(
toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
onLabel: (box) => labels.push(box),
chainages: scene.stations ?? undefined,
},
);
const total = polylineLengthM(line);
+18 -4
View File
@@ -42,6 +42,8 @@ export interface SlopeDrawArgs {
uprightRad: number;
/** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */
dragging: boolean;
/** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
chainages?: ReadonlyArray<number> | null;
}
export interface SlopeLabels {
@@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
/** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */
let cachedLine: ReadonlyArray<Vertex> | null = null;
let cachedInterval = 0;
/** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */
let cachedKey = "";
let chainages: number[] = [];
let heights: Array<number | null> = [];
/** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */
@@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
let asking: ReadonlyArray<Vertex> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function schedule(line: ReadonlyArray<Vertex>, intervalM: number): void {
function schedule(
line: ReadonlyArray<Vertex>,
intervalM: number,
marks: ReadonlyArray<number> | null,
): void {
if (asking === line) return;
if (timer) clearTimeout(timer);
asking = line;
timer = setTimeout(() => {
timer = null;
const wanted = stationChainages(lengthOf(line), intervalM);
const total = lengthOf(line);
const wanted = marks
? [...marks.filter((value) => value < total - 1e-6), total]
: stationChainages(total, intervalM);
const points = pointsAt(line, wanted);
void fetchRouteElevations(params.projectId, points)
.then((z) => {
if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다.
cachedLine = line;
cachedInterval = intervalM;
cachedKey = marks?.join() ?? "";
chainages = wanted;
heights = z;
stationPoints = points;
@@ -160,9 +172,11 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
const { line, intervalM } = args;
if (line.length < 2 || !(intervalM > 0)) return 0;
if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다.
const fresh = cachedLine === line && cachedInterval === intervalM;
const marks = args.chainages ?? null;
const fresh =
cachedLine === line && cachedInterval === intervalM && cachedKey === (marks?.join() ?? "");
if (!fresh) {
if (!args.dragging) schedule(line, intervalM);
if (!args.dragging) schedule(line, intervalM, marks);
return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다.
}
// 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 —
@@ -89,7 +89,10 @@ if (typeof document !== "undefined")
/** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */
export function arrowsBelongToProfile(): boolean {
return !!lastPointer?.closest(".b05-route-profile");
// 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2).
return !!lastPointer?.closest(
".b05-route-profile, .b05-profile__masshaul-overlay, .b05-drainage",
);
}
/** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김.
@@ -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": ["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": ["SE"]},
{"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": []}
]
@@ -23,6 +23,7 @@ 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';
const ALL_COMMANDS: CadCommand[] = [
...DRAW_COMMANDS,
@@ -112,11 +113,24 @@ export const TOOL_STATE_MACHINES = Object.fromEntries(
])
) as Record<Tool, AnyToolMachine>;
/** 명령행 입력 해석 — 정확한 이름·별칭이 먼저, 없으면 접두사 후보의 첫 항목 */
/** 내 별칭(☰ [설정] · `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();
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) ??
matchCommandPrefixes(text)[0]
);
}
/** 접두사가 같은 명령 후보 (커서 옆 자동완성 목록에 쓴다) */
@@ -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) 선") */
@@ -2,6 +2,7 @@
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent } from '../App.types';
import { releaseOnePoint } from '../inputController/input-angle-lock';
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
import type { CadCommand } from './command.types';
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
@@ -38,6 +39,7 @@ export function runCommand(command: CadCommand): string {
}
lastCommandId = command.id;
releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음
if (command.machine) {
setActiveToolActor(new Actor(command.machine));
@@ -1,4 +1,5 @@
import { type Point, Vector } from '@flatten-js/core';
import { getAngleLock, pointAtLock, projectOnLock } from '../inputController/input-angle-lock.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
@@ -12,21 +13,26 @@ import {
/**
* Various tools need to convert user input into a point
* This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events
* 각도 고정(`<30`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로.
* @param startPoint
* @param event
*/
export function getPointFromEvent(startPoint: Point | null, event: PointInputEvent): Point {
const locked = startPoint !== null && getAngleLock() !== null;
if (event.type === ActorEvent.DRAW) {
return (event as DrawEvent).drawController.getWorldMouseLocation();
const mouse = (event as DrawEvent).drawController.getWorldMouseLocation();
return locked ? projectOnLock(startPoint, mouse) : mouse;
}
if (event.type === ActorEvent.MOUSE_CLICK) {
return (event as MouseClickEvent).worldMouseLocation;
const click = (event as MouseClickEvent).worldMouseLocation;
return locked ? projectOnLock(startPoint, click) : click;
}
if (event.type === ActorEvent.NUMBER_INPUT) {
if (!startPoint) {
throw new Error('Cannot get relative point by distance if no start point is provided');
}
const distance = (event as NumberInputEvent).value;
if (locked) return pointAtLock(startPoint, distance);
// Direction indicated by the startPoint and the mouse location
const direction = new Vector(
event.worldMouseLocation.x - startPoint.x,
@@ -9,12 +9,33 @@ import { getActiveToolActor } from '../state.ts';
export const isImeKey = (evt: KeyboardEvent): boolean =>
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
/** 활성 도구의 지금 단계 설정(평평한 단계만) */
const activeStateConfig = () => {
const snapshot = getActiveToolActor()?.getSnapshot();
return snapshot?.machine?.states?.[snapshot?.value]?.config;
};
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
export function activeToolAcceptsText(): boolean {
const snapshot = getActiveToolActor()?.getSnapshot();
return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT;
return !!activeStateConfig()?.on?.TEXT_INPUT;
}
/** 지금 단계가 점(`x,y`)을 받는가 — 한 번 스냅 이름 · 선택지 판정 */
export function activeToolWantsPoint(): boolean {
return !!activeStateConfig()?.on?.ABSOLUTE_POINT_INPUT;
}
/** 지금 단계가 이 글을 받는가 — 점 단계는 선택지 글자(meta.options · 선 C · U)만 · 나머지는 명령 이름으로 */
export function activeToolTakesText(text: string): boolean {
if (!activeToolAcceptsText()) return false;
if (!activeToolWantsPoint()) return true;
const options = (activeStateConfig()?.meta as { options?: string[] } | undefined)?.options;
return !!options?.includes(text);
}
/** 한글 받개에 초점을 줄 단계 — 글은 받되 점 단계 선택지(영문 C · U)는 아님 */
export const activeToolWantsIme = (): boolean => activeToolAcceptsText() && !activeToolWantsPoint();
/** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */
export function activeToolWantsFreeText(): boolean {
const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as
@@ -0,0 +1,52 @@
/**
* 각도 고정 `<각도` — 오토캐드 각도 재지정. 다음 한 점만 기준점에서 그 방향(동쪽 0° 반시계 +).
* 커서 · 클릭은 고정 방향 직선 위로 내리고 · 숫자는 그 방향 길이. 점 하나 쓰면 · Esc · 새 명령이면 풂.
* 동적 입력(커서 옆 길이 · 각도 두 칸) — 값 넣고 Tab = 그 칸 잠금 + 다음 칸 · 각도 칸 잠금 = 각도 고정.
*/
import { Point } from '@flatten-js/core';
import { polarToPoint } from './input-patterns.ts';
let lockedDegrees: number | null = null;
/** 동적 입력 — 지금 칸 · 잠근 길이 */
let dynamicField: 'length' | 'angle' = 'length';
let lockedLength: number | null = null;
export const getAngleLock = (): number | null => lockedDegrees;
export const setAngleLock = (degrees: number | null) => {
lockedDegrees = degrees;
};
export const getDynamicField = () => dynamicField;
export const getLockedLength = () => lockedLength;
/** 한 점을 쓴 뒤 · Esc · 새 명령 — 각도 고정 · 동적 입력 잠금을 모두 풂 */
export function releaseOnePoint() {
lockedDegrees = null;
lockedLength = null;
dynamicField = 'length';
}
/** Tab — 친 숫자를 지금 칸에 잠그고 다음 칸으로(빈 Tab 은 칸만 바꿈) · 숫자 아니면 그대로 둠 */
export function tabDynamicInput(value: number | null) {
if (value !== null && dynamicField === 'length') lockedLength = value;
if (value !== null && dynamicField === 'angle') lockedDegrees = value;
dynamicField = dynamicField === 'length' ? 'angle' : 'length';
}
/** 커서 옆 안내 줄 */
export const angleLockHint = (): string | null =>
lockedDegrees === null ? null : `각도 ${lockedDegrees}° 고정 — 길이 입력`;
/** 기준점에서 고정 방향으로 거리만큼 간 점 */
export function pointAtLock(base: Point, distance: number): Point {
const offset = polarToPoint(distance, lockedDegrees ?? 0);
return new Point(base.x + offset.x, base.y + offset.y);
}
/** 점을 기준점 · 고정 방향 직선 위로 내림(커서 · 클릭) */
export function projectOnLock(base: Point, point: Point): Point {
const unit = polarToPoint(1, lockedDegrees ?? 0);
const along = (point.x - base.x) * unit.x + (point.y - base.y) * unit.y;
return new Point(base.x + unit.x * along, base.y + unit.y * along);
}
@@ -17,6 +17,7 @@ import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-poi
import { pickRadius } from '../helpers/pick-radius.ts';
import {
getActiveToolActor,
getAngleGuideOriginPoint,
getAngleStep,
getCanvas,
getPickableEntities,
@@ -44,33 +45,42 @@ import {
} from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
type MouseClickEvent,
type NumberInputEvent,
type RelativePointInputEvent,
type TextInputEvent,
} from '../tools/tool.types.ts';
import {
activeToolAcceptsText,
activeToolTakesText,
activeToolWantsFreeText,
activeToolWantsIme,
activeToolWantsPoint,
ImeSink,
isImeKey,
isPlainSpace,
} from './ime-sink.ts';
import { drawInputField } from './input-field-draw.ts';
import { drawInputField, dynamicInputLine } from './input-field-draw.ts';
import { ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import { attachTouch } from './input-touch.ts';
import {
ABSOLUTE_POINT_REGEXP,
ABSOLUTE_POLAR_REGEXP,
ANGLE_LOCK_REGEXP,
NUMBER_REGEXP,
polarToPoint,
RELATIVE_POINT_REGEXP,
RELATIVE_POLAR_REGEXP,
parsePointText,
type PointTextEvent,
} from './input-patterns.ts';
import {
angleLockHint,
getAngleLock,
getDynamicField,
getLockedLength,
releaseOnePoint,
setAngleLock,
tabDynamicInput,
} from './input-angle-lock.ts';
import {
getSnapOverride,
isSnapName,
setSnapOverride,
snapOverrideHint,
snapOverridePoint,
} from './input-snap-override.ts';
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
@@ -125,7 +135,7 @@ export class InputController {
}
public draw(drawController: ScreenCanvasDrawController) {
this.ime.sync(activeToolAcceptsText());
this.ime.sync(activeToolWantsIme());
const toolInstruction = getLastStateInstructions();
const texts: string[] = [];
if (toolInstruction) {
@@ -135,6 +145,20 @@ export class InputController {
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
texts.push(`${roundedX},${roundedY}`);
}
const base = getAngleGuideOriginPoint();
if (base && this.dynamicInputApplies()) {
texts.push(
dynamicInputLine(
base,
drawController.getWorldMouseLocation(),
getDynamicField(),
this.text,
getLockedLength(),
getAngleLock()
)
);
}
texts.push(...compact([angleLockHint(), snapOverrideHint()]));
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
texts.push(...this.getCommandSuggestions());
// 입력 글(조합 중인 한글도 함께)
@@ -184,7 +208,7 @@ export class InputController {
return;
}
this.sendMouseClick(evt);
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
this.ime.sync(activeToolWantsIme()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
}
}
@@ -211,9 +235,14 @@ export class InputController {
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
let worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
if (getSnapOverride()) {
const snapped = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint());
if (!snapped) return; // 맞는 점 없음 — 다시 누르기를 기다림
worldMouseLocation = snapped;
}
getActiveToolActor()?.send({
this.sendPointEvent({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
@@ -382,7 +411,13 @@ export class InputController {
return;
}
if (evt.key === 'Tab') {
// Tab => move keyboard focus
// 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
if (!this.dynamicInputApplies()) return;
evt.preventDefault();
const isNumber = NUMBER_REGEXP.test(this.text);
if (this.text && !isNumber) return; // 숫자 아닌 글(@x,y 등)은 그대로 둠
tabDynamicInput(isNumber ? Number.parseFloat(this.text) : null);
this.text = '';
return;
}
if (evt.ctrlKey && evt.code === 'KeyV') {
@@ -419,12 +454,12 @@ export class InputController {
} else if (evt.key === 'Enter') {
// User wants to submit the input or submit the action
this.handleEnterKey(true);
this.ime.sync(activeToolAcceptsText());
this.ime.sync(activeToolWantsIme());
} else if (evt.key === ' ' && !activeToolWantsFreeText()) {
// Space = Enter(오토캐드) · 글 단계는 아래 띄어쓰기 · 수정키 · 길게 눌러 반복은 무시
if (isPlainSpace(evt)) {
this.handleEnterKey(true);
this.ime.sync(activeToolAcceptsText());
this.ime.sync(activeToolWantsIme());
}
} else if (evt.key === 'ArrowDown') {
// Move the screen down
@@ -448,6 +483,8 @@ export class InputController {
}
public handleEscapeKey() {
releaseOnePoint();
setSnapOverride(null);
if (getSelectedEntities().length > 0) {
// Deselect entities
setSelectedEntityIds([]);
@@ -473,9 +510,12 @@ export class InputController {
/** @param repeatIfIdle 키보드 Enter · Space — 빈 입력 + 선택 도구 대기면 마지막 명령 반복(우클릭은 확정만) */
public handleEnterKey(repeatIfIdle = false) {
// submit the text as input to the active tool and clear the input field
const activeToolCanHandleTextInput = activeToolAcceptsText();
if (this.text === '') {
const lockedLength = getLockedLength();
if (this.text === '' && lockedLength !== null && this.dynamicInputApplies()) {
// 동적 입력 길이를 잠근 채 Enter — 그 길이(방향 = 각도 고정 · 아니면 커서)
this.sendPointEvent(this.distanceEvent(lockedLength));
} else if (this.text === '') {
console.log('ENTER: ', {
text: this.text,
activeTool: getActiveToolActor(),
@@ -495,17 +535,33 @@ export class InputController {
if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') {
setActiveToolActor(new Actor(selectToolStateMachine));
}
} else if (activeToolCanHandleTextInput) {
console.log('TEXT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// Send the text to the active tool
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
} else if (activeToolWantsFreeText()) {
// 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글
this.sendText();
} else if (
getDynamicField() === 'angle' &&
NUMBER_REGEXP.test(this.text) &&
this.dynamicInputApplies()
) {
// 동적 입력 각도 칸 — 각도 고정 · 길이를 잠갔으면 바로 그 점
setAngleLock(Number.parseFloat(this.text));
this.text = '';
if (lockedLength !== null) this.sendPointEvent(this.distanceEvent(lockedLength));
} else if (ANGLE_LOCK_REGEXP.test(this.text)) {
// 각도 고정 `<30` — 다음 한 점만 그 방향
setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1]));
this.text = '';
} else if (this.pointEventFromText()) {
// 거리 `100` · `x,y` · `@x,y` · `@d<a` · `d<a`
this.sendPointEvent(this.pointEventFromText() as PointTextEvent);
this.text = '';
} else if (isSnapName(this.text) && activeToolWantsPoint()) {
// 한 번 스냅 END · MID · … — 다음 한 점만
setSnapOverride(this.text);
this.text = '';
} else if (activeToolTakesText(this.text)) {
// 지금 단계의 선택지(선 C · U · 배열 L/R · 숫자 단계 글)
this.sendText();
} else if (resolveCommandInput(this.text)) {
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
const command = resolveCommandInput(this.text);
@@ -513,91 +569,50 @@ export class InputController {
runCommand(command);
}
this.text = '';
} else if (NUMBER_REGEXP.test(this.text)) {
console.log(' NUMBER_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered a number. eg: 100
getActiveToolActor()?.send({
type: ActorEvent.NUMBER_INPUT,
value: Number.parseFloat(this.text),
worldMouseLocation:
getSnapPointOnAngleGuide()?.point ||
getSnapPoint()?.point ||
getScreenCanvasDrawController().getWorldMouseLocation(),
} as NumberInputEvent);
this.text = '';
} else if (ABSOLUTE_POINT_REGEXP.test(this.text)) {
console.log('ABSOLUTE_POINT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered coordinates to an absolute point on the canvas. eg: 100, 200
const match = ABSOLUTE_POINT_REGEXP.exec(this.text);
if (!match) {
return;
}
const x = Number.parseFloat(match[1]);
const y = Number.parseFloat(match[2]);
getActiveToolActor()?.send({
type: ActorEvent.ABSOLUTE_POINT_INPUT,
value: new Point(x, y),
} as AbsolutePointInputEvent);
this.text = '';
} else if (RELATIVE_POINT_REGEXP.test(this.text)) {
console.log('RELATIVE_POINT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered coordinates to a relative point on the canvas. eg: @100, 200
const match = RELATIVE_POINT_REGEXP.exec(this.text);
if (!match) {
return;
}
const x = Number.parseFloat(match[1]);
const y = Number.parseFloat(match[2]);
getActiveToolActor()?.send({
type: ActorEvent.RELATIVE_POINT_INPUT,
value: new Point(x, y),
} as RelativePointInputEvent);
this.text = '';
} else if (RELATIVE_POLAR_REGEXP.test(this.text)) {
// 직전 점에서 거리·각도로 이동. 예: @100<45
const match = RELATIVE_POLAR_REGEXP.exec(this.text);
if (!match) {
return;
}
getActiveToolActor()?.send({
type: ActorEvent.RELATIVE_POINT_INPUT,
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
} as RelativePointInputEvent);
this.text = '';
} else if (ABSOLUTE_POLAR_REGEXP.test(this.text)) {
// 원점에서 거리·각도로 지정한 점. 예: 100<45
const match = ABSOLUTE_POLAR_REGEXP.exec(this.text);
if (!match) {
return;
}
getActiveToolActor()?.send({
type: ActorEvent.ABSOLUTE_POINT_INPUT,
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
} as AbsolutePointInputEvent);
this.text = '';
} else {
console.log('TEXT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// Send the text to the active tool
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
this.text = '';
this.sendText();
}
}
private sendText() {
console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor() });
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
this.text = '';
}
/** 거리의 방향 = 스냅 · 가이드 · 커서 */
private distanceCursor() {
return (
getSnapPointOnAngleGuide()?.point ||
getSnapPoint()?.point ||
getScreenCanvasDrawController().getWorldMouseLocation()
);
}
private distanceEvent(value: number): PointTextEvent {
return { type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: this.distanceCursor() };
}
/** 친 글을 점 · 거리 사건으로 */
private pointEventFromText(): PointTextEvent | null {
return parsePointText(this.text, () => this.distanceCursor());
}
/** 동적 입력(길이 · 각도 칸 · Tab) — 기준점이 있는 점 단계에서만 */
private dynamicInputApplies(): boolean {
return activeToolWantsPoint() && !!getAngleGuideOriginPoint();
}
/** 점 하나를 도구에 보냄 — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
getActiveToolActor()?.send(event);
releaseOnePoint();
setSnapOverride(null);
}
public handleUndo(evt: KeyboardEvent) {
evt.preventDefault();
undo();
@@ -84,3 +84,27 @@ export function drawInputField(
);
});
}
/**
* 동적 입력 두 칸 — `[길이 100 🔒] ▸[각도 30_]`. 잠근 칸은 🔒 · 지금 칸은 ▸ + 친 글 ·
* 나머지는 기준점→커서 실측(각도 = 동쪽 0° 반시계 +).
*/
export function dynamicInputLine(
base: Point,
mouse: Point,
field: 'length' | 'angle',
typing: string,
lockedLength: number | null,
lockedDegrees: number | null
): string {
const live = {
length: Math.hypot(mouse.x - base.x, mouse.y - base.y),
angle: ((Math.atan2(mouse.y - base.y, mouse.x - base.x) * 180) / Math.PI + 360) % 360,
};
const cell = (name: 'length' | 'angle', label: string, locked: number | null) => {
const active = field === name;
const value = active && typing ? `${typing}_` : (locked ?? Number(live[name].toFixed(2)));
return `${active ? '▸' : ' '}[${label} ${value}${locked !== null ? ' 🔒' : ''}]`;
};
return `${cell('length', '길이', lockedLength)} ${cell('angle', '각도', lockedDegrees)}`;
}
@@ -1,17 +1,65 @@
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 · 각도 고정 꼴 */
import { Point } from '@flatten-js/core';
import {
type AbsolutePointInputEvent,
ActorEvent,
type NumberInputEvent,
type RelativePointInputEvent,
} from '../tools/tool.types.ts';
export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
/** 부호 있는 실수 한 개 — `-5` · `.5` · `1.5` */
const SIGNED = '(-?(?:[0-9]+(?:[.][0-9]+)?|[.][0-9]+))';
export const NUMBER_REGEXP = new RegExp(`^${SIGNED}$`);
export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
/** 각도 고정 — `<30` (다음 한 점만 그 방향) */
export const ANGLE_LOCK_REGEXP = new RegExp(`^<[ ]*${SIGNED}$`);
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
/** 거리·각도(도)를 x·y 변위로 바꾼다 — 각도 = 동쪽 0° 반시계 + */
export function polarToPoint(distance: number, degrees: number): Point {
const radians = (degrees * Math.PI) / 180;
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
export type PointTextEvent = NumberInputEvent | AbsolutePointInputEvent | RelativePointInputEvent;
/** 친 글 → 점 · 거리 사건(숫자 · `x,y` · `@x,y` · `@d<a` · `d<a`) · 아니면 null · 커서는 숫자일 때만 읽음 */
export function parsePointText(text: string, cursor: () => Point): PointTextEvent | null {
const num = (value: string) => Number.parseFloat(value);
let match = NUMBER_REGEXP.exec(text);
if (match) {
return { type: ActorEvent.NUMBER_INPUT, value: num(match[1]), worldMouseLocation: cursor() };
}
match = ABSOLUTE_POINT_REGEXP.exec(text);
if (match) {
return {
type: ActorEvent.ABSOLUTE_POINT_INPUT,
value: new Point(num(match[1]), num(match[2])),
};
}
match = RELATIVE_POINT_REGEXP.exec(text);
if (match) {
return {
type: ActorEvent.RELATIVE_POINT_INPUT,
value: new Point(num(match[1]), num(match[2])),
};
}
match = RELATIVE_POLAR_REGEXP.exec(text);
if (match) {
return {
type: ActorEvent.RELATIVE_POINT_INPUT,
value: polarToPoint(num(match[1]), num(match[2])),
};
}
match = ABSOLUTE_POLAR_REGEXP.exec(text);
if (match) {
return {
type: ActorEvent.ABSOLUTE_POINT_INPUT,
value: polarToPoint(num(match[1]), num(match[2])),
};
}
return null;
}
@@ -0,0 +1,80 @@
/**
* 한 번 스냅 — 점 단계에서 END · MID · CEN · INT · PER · NEA · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
* F3(객체 스냅) 꺼져 있어도 먹음. 커서 둘레에 맞는 점이 없으면 클릭을 버리고 기다림 · Esc 로 풂.
*/
import type { Point } from '@flatten-js/core';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { SnapPointType } from '../App.types.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
import { getIntersectionPoints } from '../helpers/get-intersection-points.ts';
import { queryEntitiesNearPoint } from '../helpers/spatial-index.ts';
import { getLayerById, getScreenCanvasDrawController } from '../state.ts';
const SNAP_LABELS = {
END: '끝점',
MID: '중간점',
CEN: '중심',
INT: '교차점',
PER: '수직',
NEA: '근처점',
NON: '스냅 없음',
} as const;
type SnapName = keyof typeof SNAP_LABELS;
const SNAP_TYPES: Partial<Record<SnapName, SnapPointType>> = {
END: SnapPointType.LineEndPoint,
MID: SnapPointType.LineMidPoint,
CEN: SnapPointType.CircleCenter,
};
let override: SnapName | null = null;
export const getSnapOverride = (): SnapName | null => override;
export const setSnapOverride = (name: SnapName | null) => {
override = name;
};
export const isSnapName = (text: string): text is SnapName => text in SNAP_LABELS;
/** 커서 옆 안내 줄 */
export const snapOverrideHint = (): string | null =>
override ? `${SNAP_LABELS[override]}(${override}) 한 번 — 점 지정` : null;
const closest = (points: Point[], mouse: Point, radius: number): Point | null => {
let best: Point | null = null;
let bestDistance = radius;
for (const point of points) {
const distance = Math.hypot(point.x - mouse.x, point.y - mouse.y);
if (distance <= bestDistance) {
best = point;
bestDistance = distance;
}
}
return best;
};
/** 한 번 스냅이 고른 점 · 맞는 점 없으면 null. base = 앞 점(PER 기준) */
export function snapOverridePoint(mouse: Point, base: Point | null): Point | null {
if (override === 'NON') return mouse;
// ponytail: 일반 스냅(15px)의 두 배 둘레 — 오토캐드 조준 상자 크기 설정이 필요하면 설정 모달로
const radius = (SNAP_POINT_DISTANCE * 2) / getScreenCanvasDrawController().getScreenScale();
const entities = queryEntitiesNearPoint(mouse.x, mouse.y, radius).filter(
(entity) => !getLayerById(entity.layerId)?.isLocked
);
const type = override ? SNAP_TYPES[override] : undefined;
if (type) {
const points = entities.flatMap((entity) =>
entity
.getSnapPoints()
.filter((snap) => snap.type === type)
.map((snap) => snap.point)
);
return closest(points, mouse, radius);
}
if (override === 'INT') return closest(getIntersectionPoints(entities), mouse, radius);
const near = findClosestEntity(mouse, entities);
if (!near.entity || near.distance > radius) return null;
if (override === 'NEA') return near.segment.start;
// PER — 앞 점에서 커서 아래 객체로 내린 발(선분이면 수선의 발)
if (!base) return null;
return near.entity.distanceTo(base)?.[1].start ?? null;
}
@@ -22,8 +22,8 @@ describe('CAD 휠 = 늘 줌(153-3)', () => {
expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT });
});
it('ctrl+휠(핀치)은 핀치 지수', () => {
expect(wheel(-7, 0, true)).toEqual({ delta: 7, exponent: PINCH_ZOOM_EXPONENT });
it('ctrl+휠(핀치)은 핀치 지수 · 벌리면(deltaY < 0) 확대(332)', () => {
expect(wheel(-7, 0, true)).toEqual({ delta: -7, exponent: PINCH_ZOOM_EXPONENT });
});
it('델타 0 은 안 움직임', () => {
@@ -4,6 +4,7 @@ import {
WHEEL_PAGE_PX,
WHEEL_ZOOM_EXPONENT,
} from '../App.consts.ts';
import { wheelEventIn } from '../../../../A00_Common/input_prefs';
/**
* 휠 한 번 → 화면 확대(`zoomScreen` 인자). 휠은 늘 줌 · 커서 자리 고정(153-3) — 마우스 · 트랙패드를
@@ -17,8 +18,12 @@ export function wheelZoom(
const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1;
const deltaY = evt.deltaY * unitPx;
if (deltaY === 0) return null;
// Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing
// zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02).
// 방향 = ☰ [설정] 휠 확대 방향(기본 당김 = 몸 쪽으로 굴리면 확대 · 모든 화면 일체) ·
// 트랙패드 핀치는 늘 벌리면 확대. zoomScreen 은 음수 = 확대.
// 핀치(ctrl+휠)는 델타가 잘아 지수만 크게.
return { delta: -deltaY, exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT };
const size = Math.abs(deltaY);
return {
delta: wheelEventIn({ ctrlKey: evt.ctrlKey, deltaY }) ? -size : size,
exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT,
};
}
@@ -57,7 +57,7 @@ export function GET_ALIGN_TOOL_STATE(type: Tool): MachineContext {
[AlignState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to align',
meta: {
instructions: 'Select what you want to align, then ENTER',
instructions: '정렬할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheAlignTool',
@@ -91,7 +91,7 @@ export const arrayToolStateMachine = createMachine(
[ArrayState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
instructions: '배열할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
@@ -145,7 +145,7 @@ export const arrayToolStateMachine = createMachine(
],
},
[ArrayState.ASK_COPY_MODE]: {
meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' },
meta: { instructions: '배열 방식 [직선(L)/원형(R)] <직선>:' },
on: {
TEXT_INPUT: {
actions: assign(({ event }) => {
@@ -168,7 +168,7 @@ export const arrayToolStateMachine = createMachine(
},
},
[ArrayState.ASK_NUMBER_OF_COPIES]: {
meta: { instructions: 'Enter number of copies' },
meta: { instructions: '복사 개수 입력:' },
on: {
NUMBER_INPUT: [
{
@@ -196,7 +196,7 @@ export const arrayToolStateMachine = createMachine(
},
},
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
meta: { instructions: 'Click to define the start of the distance vector' },
meta: { instructions: '간격 시작점 지정:' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
@@ -216,7 +216,7 @@ export const arrayToolStateMachine = createMachine(
},
},
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
meta: { instructions: 'Click to define the end of the distance vector' },
meta: { instructions: '간격 끝점 지정:' },
on: {
RELATIVE_POINT_INPUT: {
actions: assign(({ event, context }) => ({
@@ -245,7 +245,7 @@ export const arrayToolStateMachine = createMachine(
},
},
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
meta: { instructions: 'Click to set pivot point' },
meta: { instructions: '회전 중심점 지정:' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
@@ -265,7 +265,7 @@ export const arrayToolStateMachine = createMachine(
},
},
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
meta: { instructions: 'Enter angle per step (in degrees)' },
meta: { instructions: '한 칸 각도(도) 입력:' },
on: {
NUMBER_INPUT: {
target: ArrayState.EXECUTE_COPY,
@@ -60,7 +60,7 @@ export const circleToolStateMachine = createMachine(
[CircleState.WAITING_FOR_CENTER_POINT]: {
description: 'Select the center point of the circle tool',
meta: {
instructions: 'Select the center point of the circle',
instructions: '중심점 지정 (x,y):',
},
on: {
MOUSE_CLICK: {
@@ -79,7 +79,7 @@ export const circleToolStateMachine = createMachine(
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
description: 'Select a point on the circle',
meta: {
instructions: 'Select the point on the circle',
instructions: '원 위의 점 지정 또는 반지름 입력:',
},
on: {
DRAW: {
@@ -10,7 +10,8 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -96,7 +97,7 @@ export const copyToolStateMachine = createMachine(
[CopyState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
instructions: '복사할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
@@ -135,9 +136,9 @@ export const copyToolStateMachine = createMachine(
},
},
[CopyState.WAITING_FOR_START_COPY_POINT]: {
description: 'Select the start of the copy line',
description: '기준점 지정 (x,y):',
meta: {
instructions: 'Select the start of the copy line',
instructions: '기준점 지정 (x,y):',
},
always: {
actions: CopyAction.ENABLE_HELPERS,
@@ -147,6 +148,10 @@ export const copyToolStateMachine = createMachine(
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
actions: CopyAction.DESELECT_ENTITIES,
target: CopyState.INIT,
@@ -154,9 +159,9 @@ export const copyToolStateMachine = createMachine(
},
},
[CopyState.WAITING_FOR_END_COPY_POINT]: {
description: 'Select the end of the copy line',
description: '둘째 점 지정 (길이 · <각도 · @x,y):',
meta: {
instructions: 'Select the end of the copy line',
instructions: '둘째 점 지정 (길이 · <각도 · @x,y):',
},
on: {
DRAW: {
@@ -166,6 +171,18 @@ export const copyToolStateMachine = createMachine(
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
NUMBER_INPUT: {
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
RELATIVE_POINT_INPUT: {
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
actions: CopyAction.DESELECT_ENTITIES,
target: CopyState.INIT,
@@ -191,10 +208,9 @@ export const copyToolStateMachine = createMachine(
setShouldDrawHelpers(true);
},
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
startPoint: (event as MouseClickEvent).worldMouseLocation,
};
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return { startPoint };
}),
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
@@ -208,7 +224,9 @@ export const copyToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))),
originalSelectedEntities: compact(
selectedEntities.map((entity) => cloneKeepingLook(entity))
),
copiedEntities: selectedEntities,
};
}),
@@ -217,11 +235,13 @@ export const copyToolStateMachine = createMachine(
throw new Error('[COPY] Calling draw temp copy line without a start point');
}
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
// Copy the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
const movedEntities = context.originalSelectedEntities.map((entity) =>
cloneKeepingLook(entity)
);
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
@@ -245,8 +265,11 @@ export const copyToolStateMachine = createMachine(
}
// Copy the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const copiedEntities = context.originalSelectedEntities.map((entity) =>
cloneKeepingLook(entity)
);
moveEntities(
copiedEntities,
currentEndPoint.x - context.startPoint.x,
@@ -60,9 +60,9 @@ export const eraserToolStateMachine = createMachine(
},
},
[EraserState.WAITING_FOR_FIRST_CLICK]: {
description: 'Select a line segment to delete',
description: '지울 선 선택',
meta: {
instructions: 'Select a line segment to delete',
instructions: '지울 선 선택',
},
on: {
// TODO implement a DRAW action to draw the segment that will be erased in dotted line
@@ -176,7 +176,9 @@ export function createSequenceTool(config: SequenceToolConfig) {
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
if (!config.preview) return;
const ghosts = config.preview(makeInput(context.values, cursorPoint(event), context.picks));
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
const ghosts = config.preview(makeInput(context.values, cursor, context.picks));
setGhostHelperEntities(ghosts);
};
@@ -281,8 +283,10 @@ export function createSequenceTool(config: SequenceToolConfig) {
on.DRAW = { actions: drawPreview };
on.MOUSE_CLICK = { actions: pushPoint, target };
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
on.RELATIVE_POINT_INPUT = { actions: pushPoint, target };
on.NUMBER_INPUT = { actions: pushPoint, target };
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
const hasBase = ({ context }: { context: SequenceContext }) => !!lastPoint(context.values);
on.RELATIVE_POINT_INPUT = { guard: hasBase, actions: pushPoint, target };
on.NUMBER_INPUT = { guard: hasBase, actions: pushPoint, target };
if (config.repeatLastStep && index === lastIndex) {
on.ENTER = { target: COMMIT_STATE };
}
@@ -70,9 +70,9 @@ export const imageImportToolStateMachine = createMachine(
},
},
[ImageImportState.WAIT_FOR_IMAGE_DATA]: {
description: 'Select an image file to import',
description: '가져올 그림 파일 선택',
meta: {
instructions: 'Select an image file to import',
instructions: '가져올 그림 파일 선택',
},
on: {
[ActorEvent.FILE_SELECTED]: {
@@ -85,9 +85,9 @@ export const imageImportToolStateMachine = createMachine(
},
},
[ImageImportState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the imageImport',
description: '그림 첫 모서리 지정:',
meta: {
instructions: 'Select the start point of the imageImport',
instructions: '그림 첫 모서리 지정:',
},
on: {
MOUSE_CLICK: {
@@ -101,9 +101,9 @@ export const imageImportToolStateMachine = createMachine(
},
},
[ImageImportState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the imageImport',
description: '그림 맞은 모서리 지정:',
meta: {
instructions: 'Select the end point of the imageImport',
instructions: '그림 맞은 모서리 지정:',
},
on: {
DRAW: {
@@ -2,6 +2,7 @@ import type { Point } from '@flatten-js/core';
import { LineEntity } from '../entities/LineEntity';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
@@ -14,12 +15,22 @@ import {
} from '../state';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import type {
DrawEvent,
PointInputEvent,
StateEvent,
TextInputEvent,
ToolContext,
} from './tool.types';
import { selectToolStateMachine } from './select-tool.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
export interface LineContext extends ToolContext {
startPoint: Point | null;
/** 이번 이어 그리기의 첫 점 — 닫기(C) */
firstPoint: Point | null;
/** 이번 이어 그리기에서 그린 구간 — 명령취소(U) */
segments: LineEntity[];
}
export enum LineState {
@@ -33,6 +44,8 @@ export enum LineAction {
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
CLOSE_LINE = 'CLOSE_LINE',
UNDO_SEGMENT = 'UNDO_SEGMENT',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
}
@@ -44,6 +57,8 @@ export const lineToolStateMachine = createMachine(
},
context: {
startPoint: null,
firstPoint: null,
segments: [],
type: Tool.LINE,
},
initial: LineState.INIT,
@@ -58,7 +73,7 @@ export const lineToolStateMachine = createMachine(
[LineState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the line',
meta: {
instructions: 'Select the start point of the line',
instructions: '첫 점 지정 (x,y):',
},
on: {
MOUSE_CLICK: {
@@ -77,7 +92,8 @@ export const lineToolStateMachine = createMachine(
[LineState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the line',
meta: {
instructions: 'Select the end point of the line',
instructions: '다음 점 지정 또는 [닫기(C)/명령취소(U)]:',
options: ['C', 'U'],
},
on: {
DRAW: {
@@ -99,6 +115,21 @@ export const lineToolStateMachine = createMachine(
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
TEXT_INPUT: [
{
// 닫기 — 둘째 선부터 · 첫 점으로 잇고 끝
guard: ({ context, event }) =>
(event as TextInputEvent).value === 'C' && context.segments.length >= 2,
actions: LineAction.CLOSE_LINE,
target: LineState.INIT,
},
{
// 명령취소 — 마지막 구간만
guard: ({ context, event }) =>
(event as TextInputEvent).value === 'U' && context.segments.length > 0,
actions: LineAction.UNDO_SEGMENT,
},
],
ESC: {
target: LineState.INIT,
},
@@ -118,6 +149,8 @@ export const lineToolStateMachine = createMachine(
setAngleGuideOriginPoint(null);
return {
startPoint: null,
firstPoint: null,
segments: [],
};
}),
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
@@ -125,13 +158,15 @@ export const lineToolStateMachine = createMachine(
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
firstPoint: startPoint,
segments: [],
};
}),
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
const activeLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation()
getPointFromEvent(context.startPoint, event as DrawEvent)
);
activeLine.lineColor = getActiveLineColor();
activeLine.lineWidth = getActiveLineWidth();
@@ -159,8 +194,28 @@ export const lineToolStateMachine = createMachine(
setAngleGuideOriginPoint(endPoint);
return {
startPoint: endPoint,
segments: [...context.segments, activeLine],
};
}),
[LineAction.CLOSE_LINE]: ({ context }) => {
const closing = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
context.firstPoint as Point
);
closing.lineColor = getActiveLineColor();
closing.lineWidth = getActiveLineWidth();
closing.lineDash = getActiveLineDash();
addEntities([closing], true);
},
[LineAction.UNDO_SEGMENT]: assign(({ context }) => {
const last = context.segments[context.segments.length - 1];
deleteEntities([last], true);
const startPoint = last.getStartPoint();
setGhostHelperEntities([new LineEntity(getActiveLayerId(), startPoint, startPoint)]);
setAngleGuideOriginPoint(startPoint);
return { startPoint, segments: context.segments.slice(0, -1) };
}),
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
@@ -11,7 +11,8 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -97,7 +98,7 @@ export const moveToolStateMachine = createMachine(
[MoveState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to move',
meta: {
instructions: 'Select what you want to move, then ENTER',
instructions: '이동할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheMoveTool',
@@ -136,9 +137,9 @@ export const moveToolStateMachine = createMachine(
},
},
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
description: 'Select the start of the move line',
description: '기준점 지정 (x,y):',
meta: {
instructions: 'Select the start of the move line',
instructions: '기준점 지정 (x,y):',
},
always: {
actions: MoveAction.ENABLE_HELPERS,
@@ -148,6 +149,10 @@ export const moveToolStateMachine = createMachine(
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
target: MoveState.WAITING_FOR_END_MOVE_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
target: MoveState.WAITING_FOR_END_MOVE_POINT,
},
ESC: {
actions: MoveAction.DESELECT_ENTITIES,
target: MoveState.INIT,
@@ -155,9 +160,9 @@ export const moveToolStateMachine = createMachine(
},
},
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
description: 'Select the end of the move line',
description: '둘째 점 지정 (길이 · <각도 · @x,y):',
meta: {
instructions: 'Select the end of the move line',
instructions: '둘째 점 지정 (길이 · <각도 · @x,y):',
},
on: {
DRAW: {
@@ -167,6 +172,18 @@ export const moveToolStateMachine = createMachine(
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
target: MoveState.WAITING_FOR_SELECTION,
},
NUMBER_INPUT: {
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
target: MoveState.WAITING_FOR_SELECTION,
},
ABSOLUTE_POINT_INPUT: {
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
target: MoveState.WAITING_FOR_SELECTION,
},
RELATIVE_POINT_INPUT: {
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
target: MoveState.WAITING_FOR_SELECTION,
},
ESC: {
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
target: MoveState.INIT,
@@ -192,10 +209,9 @@ export const moveToolStateMachine = createMachine(
setShouldDrawHelpers(true);
},
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
startPoint: (event as MouseClickEvent).worldMouseLocation,
};
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return { startPoint };
}),
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
@@ -211,7 +227,9 @@ export const moveToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
originalSelectedEntities: compact(
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
),
movedEntities: selectedEntities,
};
}),
@@ -220,11 +238,13 @@ export const moveToolStateMachine = createMachine(
throw new Error('[MOVE] Calling draw temp move line without a start point');
}
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
// Move the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true));
const movedEntities = context.originalSelectedEntities.map((entity) =>
cloneKeepingLook(entity, true)
);
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
@@ -248,7 +268,8 @@ export const moveToolStateMachine = createMachine(
}
// Move the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
moveEntities(
context.originalSelectedEntities,
currentEndPoint.x - context.startPoint.x,
@@ -75,7 +75,7 @@ export const peditToolStateMachine = createMachine(
[PeditState.WAITING_FOR_SELECTION]: {
description: 'Select the entities that you want to combine into a polyline',
meta: {
instructions: 'Select what you want to combine into a polyline, then ENTER',
instructions: '폴리선으로 합칠 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideThePeditTool',
@@ -53,9 +53,9 @@ export const rectangleToolStateMachine = createMachine(
},
},
[RectangleState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the rectangle',
description: '첫 모서리 지정 (x,y):',
meta: {
instructions: 'Select the start point of the rectangle',
instructions: '첫 모서리 지정 (x,y):',
},
on: {
MOUSE_CLICK: {
@@ -69,9 +69,9 @@ export const rectangleToolStateMachine = createMachine(
},
},
[RectangleState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the rectangle',
description: '맞은 모서리 지정 (@x,y):',
meta: {
instructions: 'Select the end point of the rectangle',
instructions: '맞은 모서리 지정 (@x,y):',
},
on: {
DRAW: {
@@ -10,7 +10,15 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import type {
DrawEvent,
MouseClickEvent,
NumberInputEvent,
PointInputEvent,
StateEvent,
ToolContext,
} from './tool.types';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -41,6 +49,7 @@ export enum RotateAction {
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
ROTATE_SELECTION = 'ROTATE_SELECTION',
ROTATE_BY_ANGLE = 'ROTATE_BY_ANGLE',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
@@ -96,7 +105,7 @@ export const rotateToolStateMachine = createMachine(
[RotateState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to rotate',
meta: {
instructions: 'Select what you want to rotate, then ENTER',
instructions: '회전할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheRotateTool',
@@ -135,9 +144,9 @@ export const rotateToolStateMachine = createMachine(
},
},
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
description: 'Select the origin of the rotate operation',
description: '기준점 지정 (x,y):',
meta: {
instructions: 'Select the origin of the rotate operation',
instructions: '기준점 지정 (x,y):',
},
always: {
actions: RotateAction.ENABLE_HELPERS,
@@ -147,6 +156,10 @@ export const rotateToolStateMachine = createMachine(
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
},
ESC: {
actions: RotateAction.DESELECT_ENTITIES,
target: RotateState.INIT,
@@ -154,9 +167,9 @@ export const rotateToolStateMachine = createMachine(
},
},
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
description: 'Select the end of the base rotate line',
description: '회전 각도 입력 또는 기준선 끝점 지정:',
meta: {
instructions: 'Select the end of the base rotate line',
instructions: '회전 각도 입력 또는 기준선 끝점 지정:',
},
on: {
MOUSE_CLICK: {
@@ -166,6 +179,15 @@ export const rotateToolStateMachine = createMachine(
],
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
},
NUMBER_INPUT: {
// 각도(도) 바로 — 반시계 +
actions: [
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
RotateAction.ROTATE_BY_ANGLE,
RotateAction.DESELECT_ENTITIES,
],
target: RotateState.WAITING_FOR_SELECTION,
},
ESC: {
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
target: RotateState.INIT,
@@ -173,9 +195,9 @@ export const rotateToolStateMachine = createMachine(
},
},
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
description: 'Select the end of the rotate line',
description: '회전 방향 점 지정:',
meta: {
instructions: 'Select the end of the rotate line',
instructions: '회전 방향 점 지정:',
},
on: {
DRAW: {
@@ -204,11 +226,9 @@ export const rotateToolStateMachine = createMachine(
setShouldDrawHelpers(true);
},
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
...context,
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
};
const rotationOrigin = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(rotationOrigin);
return { ...context, rotationOrigin };
}),
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
({ context, event }): RotateContext => {
@@ -232,7 +252,9 @@ export const rotateToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
originalSelectedEntities: compact(
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
),
};
}),
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
@@ -279,6 +301,19 @@ export const rotateToolStateMachine = createMachine(
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[RotateAction.ROTATE_BY_ANGLE]: ({ context, event }) => {
if (!context.rotationOrigin) {
throw new Error('[ROTATE] Calling rotate by angle without a rotation origin');
}
const radians = ((event as NumberInputEvent).value * Math.PI) / 180;
const rotatedEntities = compact(
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
);
for (const entity of rotatedEntities) entity.rotate(context.rotationOrigin, radians);
addEntities(rotatedEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
@@ -57,7 +57,7 @@ export const selectToolStateMachine = createMachine(
[SelectState.WAITING_FOR_FIRST_SELECT_POINT]: {
description: 'Select a line or select the first point of a selection rectangle',
meta: {
instructions: 'Select a line or start drawing a selection rectangle',
instructions: '객체 선택 또는 선택 상자 시작',
},
on: {
MOUSE_CLICK: {
@@ -79,7 +79,7 @@ export const selectToolStateMachine = createMachine(
[SelectState.CHECK_SELECTION]: {
description: 'Checking to select one line or start drawing a selection rectangle',
meta: {
instructions: 'Select one line or start drawing a selection rectangle',
instructions: '객체 하나 선택 또는 선택 상자 시작',
},
always: [
{
@@ -95,9 +95,9 @@ export const selectToolStateMachine = createMachine(
],
},
[SelectState.WAITING_FOR_SECOND_SELECT_POINT]: {
description: 'Select the second point of a selection rectangle',
description: '선택 상자 맞은 모서리 지정',
meta: {
instructions: 'Select the second point of a selection rectangle',
instructions: '선택 상자 맞은 모서리 지정',
},
on: {
DRAW: {
@@ -25,6 +25,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "../../A00_Common/key_shortcut.ts"],
"include": ["src", "../../A00_Common/key_shortcut.ts", "../../A00_Common/input_prefs.ts"],
"exclude": ["node_modules"]
}
+2
View File
@@ -19,6 +19,7 @@ import {
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { ROUTES } from "@config/config_frontend";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
@@ -54,6 +55,7 @@ export async function renderM01MasterData(root: HTMLElement): Promise<void> {
}
root.innerHTML = "";
root.append(buildPage());
mountSettingsDrawer(user, "m01", null); // ☰ 시스템 설정 바로가기(334)
}
function buildPage(): HTMLElement {
@@ -7,6 +7,7 @@
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
* ========================================================================== */
import { wheelZoomsIn } from "../../A00_Common/touch_bridge";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
import {
@@ -454,7 +455,7 @@ export function renderDiagram(
"wheel",
(ev) => {
ev.preventDefault();
setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY);
setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332)
},
{ passive: false },
);
+266
View File
@@ -0,0 +1,266 @@
"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
우리 돈 로직과 견준 근거 후보.
읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터
다음 머리 앞까지 한 블록 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로).
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) ·
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이
하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 ·
다섯 안에 이름 1순위 하나는 늘 남김(`pick`).
자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`.
M01 · 원본 로직은 읽기만.
"""
from __future__ import annotations
import re
from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
SHEET = "단가산출"
COMMON = "보통인부"
BONUS_PARTS = ("공통", "토목")
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
_BOOK = re.compile(r"품[셈줌]")
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
_TYPOS = (("깍기", "깎기"),)
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
@lru_cache(maxsize=2)
def _trade_names(whole_id: int) -> dict[str, str]:
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
out: dict[str, str] = {}
for row in m01.loaded()[1].numbers.get("LB", {}).values():
name = str(row.get("이름") or "")
out[_flat(name)] = name
for old in row.get("옛이름") or []:
out.setdefault(_flat(old), name)
return out
def trade_names() -> dict[str, str]:
return _trade_names(id(m01.loaded()[1]))
def sources(text: str) -> list[list[str]]:
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
out = []
for hit in _BOOK.finditer(text):
num = labor_inputs._NUMBER.search(text, hit.end())
if not num or num.start() - hit.end() > 20:
continue
before = text[max(0, hit.start() - 12) : hit.start()]
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
if [book, num.group()] not in out:
out.append([book, num.group()])
return out
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {})."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "산출근거" in flat:
money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat}
return at + 1, flat.index("산출근거"), money
return 0, 1, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}`
(행 = 엑셀 1부터)."""
if not rows:
return {}
start, col, money = _columns(rows)
names = trade_names()
blocks: dict[int, dict[str, Any]] = {}
block: dict[str, Any] | None = None
for at, row in enumerate(rows[start:], start + 1):
text = row[col] if col < len(row) else None
if not isinstance(text, str) or not text.strip():
continue
head = None if text[:1].isspace() else _HEAD.match(text)
if head:
block = blocks.setdefault(
int(head.group(1)),
{
"번": int(head.group(1)),
"이름": head.group(2),
"단위": head.group(3).strip(),
"행": [at, at],
"출처": [],
"직종": [],
"금액": {
key: v
for key, c in money.items()
for v in [row[c] if c < len(row) else None]
if isinstance(v, (int, float)) and not isinstance(v, bool)
},
},
)
continue
if block is None:
continue
block["행"][1] = at
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
trade = _TRADE.match(text)
name = names.get(_flat(trade.group(1))) if trade else None
if name and name not in block["직종"]:
block["직종"].append(name)
return blocks
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
@lru_cache(maxsize=2)
def _ours(whole_id: int) -> dict[str, dict[str, Any]]:
"""{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 ·
직종 = 호표 인력(LB) 요소."""
files, whole = m01.loaded()
lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()}
out = {}
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
kind = str(row.get("구분") or "")
book, _, part = kind.partition(" ")
trades = {
lb[str(h.get("요소"))]
for h in row.get("호표") or []
if isinstance(h, dict) and str(h.get("요소")) in lb
}
clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or "")))
out[str(row.get("키"))] = {
"책": book,
"부문": part,
"구분": kind,
"조항": clauses,
"직종": trades,
}
return out
def ours() -> dict[str, dict[str, Any]]:
return _ours(id(m01.loaded()[1]))
def _head(number: str) -> str:
return number.rsplit("-", 1)[0]
def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만."""
nums = [n for book, n in theirs if book == our["책"]]
same = sorted(n for n in nums if n in our["조항"])
if same:
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
heads = {_head(n) for n in nums}
near = sorted(n for n in our["조항"] if _head(n) in heads)
if near:
return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음"
return 0.0, [], ""
def _text(line: dict[str, Any]) -> str:
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
for old, new in _TYPOS:
text = text.replace(old, new)
return text
def _loose_words(text: str) -> frozenset[str]:
"""괄호 글까지 뜻 낱말 — 방법 낱말 뺌."""
return hopyo_match.words(text.replace("(", " ").replace(")", " ")) - _PLAIN_WORDS
def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]:
"""근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 근거, _겹침, _낱말}`
점수 차례(`_` 칸은 `merge` 가 쓰고 뺌)."""
basis = line.get("근거") or {}
theirs = [list(one) for one in basis.get("출처") or []]
if not theirs:
return []
trades = set(basis.get("직종") or []) - {COMMON}
text = _text(line)
mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」
grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름
loose = _loose_words(text)
table = ours()
scored = []
for one in got["로직"]:
our = table.get(one["로직"])
if not our:
continue
score, clauses, tag = _clause(theirs, our)
if not score:
continue
shared = sorted(trades & our["직종"])
if not shared and not any(a in b or b in a for a in mine for b in one["words"]):
continue
tags = [tag]
if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS:
score += 0.1
tags.append(f"건설 {our['부문']}")
if shared:
score += 0.5
tags.append("직종 " + " · ".join(shared))
unit = hopyo_match.fits(line.get("단위"), one["단위"])
if unit:
score += 0.15
tags.append("단위 맞음")
dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)}
theirs_loose = _loose_words(one["이름"])
overlap = any(a in b or b in a for a in loose for b in theirs_loose)
full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음
why["까닭"] = " · ".join(tags)
extra = {"_겹침": dice, "_낱말": overlap or full}
scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra}))
scored.sort(key=lambda x: (-x[0], -x[1]))
return [one for _, _, one in scored]
def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례 ·
같으면 이름 겹침 큰 쪽 · 이름 · 낱말 안 겹치는 근거 후보는 겹치는 근거 후보 뒤로."""
if not basis:
return named
by_name = {c.get("로직"): c["점수"] for c in named}
out = []
for c in basis:
named_too = c["로직"] in by_name
score = round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)
out.append({**c, "점수": score, "_낱말": c["_낱말"] or named_too})
out += [c for c in named if c.get("로직") in by_name]
out.sort(key=lambda c: (-c["점수"], -c.get("_겹침", 0)))
loose = [c for c in out if c.get("_낱말") is False]
if loose and any(c.get("_낱말") for c in out):
out = [c for c in out if c.get("_낱말") is not False]
last = max(at for at, c in enumerate(out) if c.get("_낱말"))
out[last + 1 : last + 1] = loose
return [{k: v for k, v in c.items() if not k.startswith("_")} for c in out]
def pick(ordered: list[dict[str, Any]], first: Any, room: int) -> list[dict[str, Any]]:
"""후보 `room` 개 — 이름 1순위(`first` 로직 키) 하나는 늘 남기고 나머지는 차례대로."""
if room <= 0:
return []
if first is None or all(c.get("로직") != first for c in ordered):
return ordered[:room]
others = [c for c in ordered if c.get("로직") != first][: room - 1]
return [c for c in ordered if c.get("로직") == first or any(c is o for o in others)]
+22 -4
View File
@@ -11,6 +11,9 @@
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
"""
from __future__ import annotations
@@ -20,6 +23,8 @@ import re
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
COSTS = ("노무비", "재료비", "경비")
@@ -67,8 +72,8 @@ def _out(value: Decimal | None) -> int | float | None:
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음."""
wanted = (SHEET, SUMMARY)
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음."""
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
if not data:
raise ExcelError("빈 파일")
try:
@@ -212,7 +217,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
start, cols = _columns(rows, ("합계", *COSTS))
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
for row in rows[start:]:
for at, row in enumerate(rows[start:], start + 1):
head = _flat(_cell(row, cols.get("호표")))
name_ = _text(_cell(row, cols.get("품명")))
hit = _HOPYO.fullmatch(head)
@@ -224,6 +229,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
"규격": _text(_cell(row, cols.get("규격"))),
"단위": _text(_cell(row, cols.get("단위"))),
"금액": _money(row, cols, 1),
"행": [at, at],
}
)
raw.append([])
@@ -244,9 +250,15 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
}
line["갈래"] = kind_of(line)
line["행"] = hopyos[-1]["행"][1] = at
raw[-1].append(line)
if not hopyos:
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
for line in (one for lines in raw for one in lines):
ref = line["참조"] or {}
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
wrong: list[str] = []
totals = _summary(sheets.get(SUMMARY))
for hopyo, lines in zip(hopyos, raw, strict=True):
@@ -258,7 +270,13 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}}
basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()]
return {
"시트": SHEET,
"호표": out,
"단산": basis,
"검산": {"어긋남": wrong, "총괄표": len(totals)},
}
def _json(value: Any) -> Any:
+211
View File
@@ -0,0 +1,211 @@
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
읽기 전용 보기).
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
"""
from __future__ import annotations
import io
from typing import Any
from openpyxl.utils import get_column_letter
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
HEAD_ROWS = 4
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
_VALIGN = {0: "top", 1: "center"}
_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"}
_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right"))
class _Styles:
"""서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`."""
def __init__(self) -> None:
self.table: list[dict[str, Any]] = [{}]
self._index: dict[str, int] = {"{}": 0}
def add(self, style: dict[str, Any]) -> int:
key = repr(sorted(style.items()))
if not style:
return 0
if key not in self._index:
self._index[key] = len(self.table)
self.table.append(style)
return self._index[key]
def _rgb(book: Any, index: int) -> str | None:
got = book.colour_map.get(index)
return "#{:02X}{:02X}{:02X}".format(*got) if got else None
def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]:
font = book.font_list[xf.font_index]
out: dict[str, Any] = {}
if font.name != base.name:
out["글꼴"] = font.name
if font.height != base.height:
out["크기"] = font.height / 20
for key, on in (
("굵게", font.bold),
("기울임", font.italic),
("밑줄", font.underline_type),
("취소선", font.struck_out),
):
if on:
out[key] = True
color = _rgb(book, font.colour_index)
if color and color != "#000000":
out["글자색"] = color
if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)):
out["채움"] = fill
align = xf.alignment
if align.hor_align in _HALIGN:
out["가로"] = _HALIGN[align.hor_align]
if align.vert_align in _VALIGN:
out["세로"] = _VALIGN[align.vert_align]
if align.text_wrapped:
out["줄바꿈"] = True
sides = {}
for name, side in _SIDES:
line = getattr(xf.border, f"{side}_line_style")
if line:
one = {"선": _LINES.get(line, "dashed")}
color = _rgb(book, getattr(xf.border, f"{side}_colour_index"))
if color and color != "#000000":
one["색"] = color
sides[name] = one
if sides:
out["테두리"] = sides
fmt = book.format_map.get(xf.format_key)
if fmt and fmt.format_str and fmt.format_str != "General":
out["형식"] = fmt.format_str
return out
def _value(cell: Any) -> Any:
import xlrd
if cell.ctype == xlrd.XL_CELL_TEXT:
return cell.value or None
if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE):
return int(cell.value) if cell.value == int(cell.value) else cell.value
if cell.ctype == xlrd.XL_CELL_BOOLEAN:
return bool(cell.value)
if cell.ctype == xlrd.XL_CELL_ERROR:
return xlrd.error_text_from_code.get(cell.value, "#N/A")
return None
def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]:
base = book.font_list[0]
by_xf: dict[int, int] = {}
cells: dict[str, dict[str, Any]] = {}
for r in range(sh.nrows):
for c in range(sh.ncols):
cell = sh.cell(r, c)
if cell.xf_index not in by_xf:
by_xf[cell.xf_index] = styles.add(
_xf_style(book, book.xf_list[cell.xf_index], base)
)
one: dict[str, Any] = {}
if (value := _value(cell)) is not None:
one["값"] = value
if by_xf[cell.xf_index]:
one["서식"] = by_xf[cell.xf_index]
if one:
cells[f"{get_column_letter(c + 1)}{r + 1}"] = one
merged = [
f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}"
for r0, r1, c0, c1 in sh.merged_cells
]
cols = {
get_column_letter(c + 1): {
"폭": round(info.width / 256, 2),
**({"숨김": True} if info.hidden else {}),
}
for c, info in sh.colinfo_map.items()
if c < sh.ncols
}
rows = {
str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})}
for r, info in sh.rowinfo_map.items()
if r < sh.nrows and (info.height != sh.default_row_height or info.hidden)
}
frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos)
freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS}
out = {
"id": sid,
"이름": sh.name.strip(),
"칸": cells,
"병합": merged,
"열": cols,
"행": rows,
"틀고정": {k: v for k, v in freeze.items() if v},
}
if sh.default_row_height:
out["기본"] = {"행높이": sh.default_row_height / 20}
return out
def _xls(data: bytes) -> dict[str, Any]:
import xlrd
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
styles = _Styles()
found = {sh.name.strip(): sh for sh in book.sheets()}
sheets = [
_xls_sheet(book, found[name], styles, f"s{at}")
for at, name in enumerate((n for n in SHEETS if n in found), 1)
]
font = book.font_list[0]
return {
"기본": {"글꼴": font.name, "크기": font.height / 20},
"서식": styles.table,
"시트": sheets,
}
def _xlsx(name: str, data: bytes) -> dict[str, Any]:
# ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
found = hopyo_excel._sheets(name, data)
sheets = []
for at, title in enumerate((n for n in SHEETS if n in found), 1):
rows = found[title]
width = max(
(c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")),
default=0,
)
cells = {
f"{get_column_letter(c + 1)}{r + 1}": {
"값": v if isinstance(v, (int, float, bool)) else str(v)
}
for r, row in enumerate(rows)
for c, v in enumerate(row[:width])
if v not in (None, "")
}
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
return {"서식": [{}], "시트": sheets}
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
"""엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과
(행 범위)."""
xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx")
book = _xlsx(name, data) if xlsx else _xls(data)
keep = ("번", "이름", "행")
return {
"책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"},
"호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []],
"단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []],
}
+13 -9
View File
@@ -4,6 +4,8 @@
씨앗(일위대가조합.json 메모 · 읽기만)
③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 ·
뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) —
+ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 ·
로직 키로 합쳐 점수 더함) ·
③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감.
줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 /
% = 안 들어감(로직 덧줄이 셈).
@@ -27,6 +29,7 @@ from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Labor_Ref as refs
@@ -295,9 +298,10 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`."""
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
(None = 모두)."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
@@ -402,12 +406,11 @@ def match_line(
return out
top = [{**_public(x), "점수": 9} for x in found]
same = [_public(x) for x in found]
rest = [
c
for c in candidates(line, pool, got)
if {k: v for k, v in c.items() if k != "점수"} not in same
]
out["후보"] = (top + rest)[:5]
basis = hopyo_basis.candidates(line, got) if pool == "로직" else []
named = candidates(line, pool, got, None if basis else 5)
rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same]
first = next((c.get("로직") for c in named if _public(c) not in same), None)
out["후보"] = top[:5] + hopyo_basis.pick(rest, first if basis else None, 5 - len(top))
return {**out, "상태": "후보" if out["후보"] else "없음"}
@@ -461,7 +464,8 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
if no is None:
got = targets()
hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []]
brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos]
keep = ("번", "이름", "규격", "단위", "금액", "맞춤")
brief = [{k: h.get(k) for k in keep} for h in hopyos]
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready}
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
+27 -3
View File
@@ -1,5 +1,5 @@
"""M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` ·
맞춤 기억 `실무자료/맞춤.json`.
맞춤 기억 `실무자료/맞춤.json` · 엑셀꼴 격자 `실무자료/격자/<키>.json`(329-1 · 파일과 같이 지움).
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
@@ -20,6 +20,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "실무자료"
MEMORY = "맞춤"
GRID = "격자"
NO_GRID = "격자 없음 — 다시 올리면 보임"
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
@@ -42,6 +44,10 @@ def _load(path: Path) -> Any:
return None
def _grid_path(folder: str | Path, key: str) -> Path:
return _dir(folder) / GRID / _path(folder, key).name
def brief(doc: dict[str, Any]) -> dict[str, Any]:
"""목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`."""
keep = ("키", "파일", "올린때", "올린이", "시트")
@@ -69,8 +75,14 @@ def read(folder: str | Path, key: str) -> dict[str, Any]:
return doc
def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]:
"""읽은 결과 → 새 키로 씀 → 목록 한 줄."""
def save(
folder: str | Path,
name: str,
parsed: dict[str, Any],
who: Any,
grid: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄."""
with _LOCK:
have = [
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
@@ -83,16 +95,28 @@ def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dic
"올린이": who,
**parsed,
}
if grid is not None:
book = {**grid["책"], "열": key}
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
atomic_write_json(_path(folder, key), doc)
return brief(doc)
def grid(folder: str | Path, key: str) -> dict[str, Any]:
"""엑셀꼴 격자 `{책, 호표, 단산}` — 없으면(옛 파일) 404."""
doc = _load(_grid_path(folder, key))
if not isinstance(doc, dict):
raise store.StoreError(404, NO_GRID)
return doc
def delete(folder: str | Path, key: str) -> None:
path = _path(folder, key)
with _LOCK:
if not path.is_file():
raise store.StoreError(404, f"없는 실무자료 「{key}」")
path.unlink()
_grid_path(folder, key).unlink(missing_ok=True)
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
+17 -2
View File
@@ -20,6 +20,7 @@ from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
@@ -176,7 +177,11 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
parsed = hopyo_excel.read_book(name, data)
except hopyo_excel.ExcelError as error:
raise store.StoreError(400, str(error)) from error
return hopyo_store.save(store.root(), name, parsed, who)
try:
grid = hopyo_grid.read(name, data, parsed)
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
return hopyo_store.save(store.root(), name, parsed, who, grid)
@router.get("/layers/{layer}/hopyo")
@@ -205,7 +210,7 @@ def hopyo_upload(
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB ·
"""엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB ·
엉터리 · 시트 없음 400."""
folder = _root(session, layer, owner, write=True)
data = file.file.read(HOPYO_MAX_BYTES + 1)
@@ -225,6 +230,16 @@ def hopyo_read(
return _call(folder, hopyo_match.view, folder, key)
@router.get("/layers/{layer}/hopyo-grid/{key}")
def hopyo_grid_read(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` ·
옛 파일 404."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_store.grid, folder, key)
@router.get("/layers/{layer}/hopyo/{key}/{no}")
def hopyo_one(
layer: Layer,
@@ -149,3 +149,102 @@
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
.m02-hopyo-sheet__dialog > .ui-modal__body {
overflow: hidden;
}
.m02-hopyo-sheet {
display: grid;
grid-template-columns: minmax(0, 1fr) 22rem;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.m02-hopyo-sheet__grid {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.m02-hopyo-sheet__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
}
.m02-hopyo-sheet__list {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m02-hopyo-sheet__item {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-left: 3px solid transparent;
cursor: pointer;
}
.m02-hopyo-sheet__item:hover {
background: var(--color-surface);
}
.m02-hopyo-sheet__item.is-current {
border-left-color: var(--color-accent);
background: var(--color-mist-violet);
}
.m02-hopyo-sheet__no {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.m02-hopyo-sheet__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-hopyo-sheet__result {
max-height: 40%;
overflow: auto;
}
.m02-hopyo-sheet__result:empty {
display: none;
}
/* 고르기 창 근거(330) — 후보 꼬리표 · 엑셀 근거 줄 */
.m02-hopyo__basis {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-hopyo__basis.is-ok {
border-color: var(--color-success);
color: var(--color-success);
}
.m02-hopyo__source {
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
@@ -5,6 +5,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
@@ -34,6 +35,22 @@ export interface HopyoBrief {
금액: Money;
/** [들어감 수, 줄 수] */
맞춤: [number, number];
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
행?: [number, number];
}
/** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */
export interface HopyoSpan {
번: number;
이름: string;
행: [number, number];
}
/** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */
export interface HopyoGrid {
책: Workbook;
호표: HopyoSpan[];
단산: HopyoSpan[];
}
export interface HopyoView extends HopyoFile {
@@ -42,6 +59,22 @@ export interface HopyoView extends HopyoFile {
맞춤: [number, number];
}
/** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */
export interface HopyoBasisHit {
조항: string[];
직종: string[];
단위: boolean;
점수: number;
까닭: string;
}
/** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */
export interface HopyoLineBasis {
출처: [string, string][];
직종: string[];
행: [number, number];
}
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
export interface HopyoTarget {
로직?: string;
@@ -51,6 +84,8 @@ export interface HopyoTarget {
규격?: string;
단위?: string | null;
점수?: number;
/** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */
근거?: HopyoBasisHit;
}
export interface HopyoMatch {
@@ -75,6 +110,8 @@ export interface HopyoLine {
금액: Money;
비고: string;
맞춤: HopyoMatch;
/** 단가산출 근거(330 · 「단산N참조」 줄만) */
근거?: HopyoLineBasis;
}
export interface HopyoOne extends HopyoFile {
@@ -136,6 +173,10 @@ export const listHopyo = async (scope: HopyoScope): Promise<HopyoFile[]> =>
export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
call(at(scope, `/hopyo/${enc(key)}`));
/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
call(at(scope, `/hopyo-grid/${enc(key)}`));
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
call(at(scope, `/hopyo/${enc(key)}/${no}`));
@@ -2,7 +2,8 @@
* M02_MasterTemplete_Hopyo_Pick.ts
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음.
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 ·
* 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
@@ -25,7 +26,42 @@ export function withHopyo(lib: LibraryPick): LibraryPick {
{ value: "precombo", text: tc("Kind_Combo") },
{ value: HOPYO_KIND, text: "실무 호표" },
],
more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)),
more: async () => {
const rows = await listHopyoPicks(scope);
const files = new Map<string, HopyoPickRow[]>();
for (const row of rows) {
const key = `${row.층}|${row.주인 ?? ""}|${row.키}`;
files.set(key, [...(files.get(key) ?? []), row]);
}
return [
...rows.map((row) => hopyoExtra(row, lib)),
...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)),
];
},
};
}
/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */
export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra {
return {
kind: HOPYO_KIND,
key: `${key}|엑셀`,
tag: "실무 호표",
name: "엑셀로 보기 · 여러 호표",
sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`,
unit: `호표 ${n}`,
onPick: () =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) =>
m.openHopyoSheet({
scope: { layer: row.층, owner: row.주인 },
key: row.키,
파일: row.파일,
canWrite: true,
target: { layer: lib.layer, owner: lib.owner },
onImport: (got, h) =>
lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null),
}),
),
};
}
@@ -0,0 +1,282 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Sheet.ts
* 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기].
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
* · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import {
buildHopyo,
readHopyo,
readHopyoGrid,
type HopyoBrief,
type HopyoBuilt,
type HopyoGrid,
type HopyoScope,
type HopyoSpan,
} from "./M02_MasterTemplete_Hopyo_Api";
import { builtWord } from "./M02_MasterTemplete_Hopyo_View";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoSheetOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
파일: string;
/** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */
canWrite: boolean;
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
target?: HopyoScope;
/** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */
호표?: number;
/** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
}
const ILWI = "일위대가";
const DANSAN = "단가산출";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
/** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */
export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`;
/** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */
export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
let best = "A";
for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) {
const c = /^[A-Z]+/.exec(k)?.[0];
if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c;
}
return best;
}
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
export const rowsText = (행: number | [number, number]): string =>
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
export function openHopyoSheet(opt: HopyoSheetOptions): void {
let sheet: SpreadsheetHandle | null = null;
let closed = false;
let briefs: HopyoBrief[] = [];
let spans: HopyoSpan[] = [];
let ilwi = ""; // 일위대가 시트 id
let last = "O";
let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름
let current: number | null = opt.호표 ?? null;
const checked = new Set<number>();
const items = new Map<number, HTMLElement>();
const left = el("div", {
className: "m02-hopyo-sheet__grid",
children: [note("엑셀을 읽는 중…")],
});
const search = el("input", {
className: "ui-input ui-input--sm",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" },
}) as HTMLInputElement;
const list = el("div", { className: "m02-hopyo-sheet__list" });
const count = note("");
const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" });
go.disabled = true;
const result = el("div", { className: "m02-hopyo-sheet__result" });
const right = el("div", {
className: "m02-hopyo-sheet__side",
children: [
search,
el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }),
list,
result,
],
});
openModal({
title: `엑셀 보기 — ${opt.파일}`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
beforeClose: async () => {
closed = true;
sheet?.destroy();
return true;
},
mount: (body) =>
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
});
const paintGo = (): void => {
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`;
go.disabled = !checked.size;
};
/** 목록 강조 · 보이게(시트 고름은 그대로) */
function mark(no: number | null): void {
items.get(current ?? NaN)?.classList.remove("is-current");
current = no;
const row = items.get(no ?? NaN);
row?.classList.add("is-current");
row?.scrollIntoView({ block: "nearest" });
}
/** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */
function focus(no: number): void {
mark(no);
const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행;
if (!sheet || !span) return;
const text = spanText(span, last);
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
}
function paintList(): void {
const q = search.value.trim().toLowerCase();
items.clear();
const shown = briefs.filter(
(h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q),
);
list.replaceChildren(
...shown.map((h) => {
const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
box.checked = checked.has(h.번);
box.hidden = !opt.canWrite;
box.addEventListener("click", (e) => e.stopPropagation());
box.addEventListener("change", () => {
if (box.checked) checked.add(h.번);
else checked.delete(h.번);
paintGo();
});
const row = el("div", {
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
children: [
box,
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
],
});
row.addEventListener("click", () => focus(h.번));
items.set(h.번, row);
return row;
}),
);
count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`;
}
search.addEventListener("input", paintList);
async function mountSheet(grid: HopyoGrid): Promise<void> {
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
if (closed) return;
const book = structuredClone(grid.책);
const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0];
if (!first) return void left.replaceChildren(note("시트 없음"));
ilwi = first.id;
last = lastColumn(first);
book.활성 = first.id;
left.replaceChildren();
sheet = createSpreadsheet(left, book, {
readOnly: true,
onSelect: (s) => {
at = s;
if (s.시트 !== ilwi) return;
const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0]));
if (no !== null && no !== current) mark(no);
},
});
// 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈)
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return;
const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0]));
if (no !== null) focus(no);
});
}
async function load(): Promise<void> {
try {
const [file, grid] = await Promise.all([
readHopyo(opt.scope, opt.key),
readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
]);
if (closed) return;
briefs = file.호표;
spans = grid instanceof Error ? [] : grid.호표;
paintList();
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
await mountSheet(grid);
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
else if (current !== null) focus(current);
} catch (error) {
if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`));
}
}
async function importPicked(): Promise<void> {
const nos = [...checked].sort((a, b) => a - b);
const target = opt.target ?? opt.scope;
const rows: HTMLElement[] = [];
let ok = 0;
go.disabled = true;
for (const no of nos) {
const h = briefs.find((x) => x.번 === no);
if (!h || closed) continue;
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
const got = await buildHopyo(target, from);
ok += 1;
opt.onImport?.(got, h);
rows.push(
el("tr", {
attrs: { "data-built": got.참조.키 },
children: [
head,
el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }),
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
],
}),
);
} catch (error) {
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
}
result.replaceChildren(
el("table", {
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [
el("tr", {
children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) =>
el("th", { text: t }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
}
paintGo();
showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning");
}
go.addEventListener("click", () => void importPicked());
void load();
}
@@ -2,7 +2,8 @@
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만.
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
@@ -66,6 +67,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await show(got);
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet");
sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite });
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
@@ -2,7 +2,9 @@
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기].
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
* ========================================================================== */
@@ -16,15 +18,18 @@ import {
readHopyo,
readHopyoOne,
saveHopyoMemory,
type HopyoBasisHit,
type HopyoBuilt,
type HopyoGuess,
type HopyoLine,
type HopyoLineBasis,
type HopyoMemory,
type HopyoOne,
type HopyoScope,
type HopyoTarget,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { pickMaterial } from "./M02_Material_Pick";
import "./M02_MasterTemplete_Hopyo.css";
@@ -52,6 +57,9 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
/** 엑셀 보기 창(329) — 열 때 불러옴 */
const openSheet = (o: HopyoSheetOptions): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
el("div", {
@@ -109,6 +117,46 @@ export function memoryOf(
return null;
}
/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
!!b &&
b.까닭.startsWith("조항 같음") &&
b.단위 &&
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
export const basisTag = (b: HopyoBasisHit | undefined): string =>
b
? [
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
b.단위 ? "단위 같음" : "",
]
.filter(Boolean)
.join(" · ")
: "";
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
export const sourceText = (b: HopyoLineBasis | undefined): string =>
b
? [
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
]
.filter(Boolean)
.join(" · ")
: "";
/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */
export function basisPick(line: HopyoLine): HopyoTarget | null {
const m = line.맞춤;
if (m.상태 !== "후보") return null;
if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상;
return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null;
}
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
let nav = 0; // 늦게 온 응답 버림
@@ -176,6 +224,12 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
createButton({
label: "엑셀로 보기",
size: "sm",
onClick: () =>
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
}),
...(opt.canWrite ? [dropButton(file)] : []),
],
}),
@@ -225,9 +279,19 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
all.disabled = !same.length;
all.addEventListener("click", () => void confirmAll(no, same));
tools.push(all);
// 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음
if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) {
const fit = h.줄.filter((x) => basisPick(x));
const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" });
ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만";
ok.disabled = !fit.length;
ok.addEventListener("click", () => void confirmBasis(no, fit));
tools.push(ok);
}
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
make.addEventListener("click", () => void build(no, false));
tools.push(all, make);
tools.push(make);
}
const missing = h.줄.filter((x) => !x.맞춤.들어감);
view.replaceChildren(
@@ -240,6 +304,18 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
createButton({
label: "엑셀에서 보기",
size: "sm",
onClick: () =>
openSheet({
scope: opt.scope,
key,
파일: got.파일,
canWrite: opt.canWrite,
호표: no,
}),
}),
...tools,
],
}),
@@ -307,7 +383,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
variant: "ghost",
size: "sm",
onClick: () =>
choose(line, (t) =>
choose(line, got.파일, (t) =>
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
(g) => (picked.set(line.차례, g), repaint()),
fail,
@@ -355,8 +431,8 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
});
}
/** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */
function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void {
/** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */
function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void {
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
@@ -367,14 +443,48 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
close();
done(t);
};
const list = (line.맞춤.후보 ?? []).map((c) =>
createButton({
const list = (line.맞춤.후보 ?? []).map((c) => {
const button = createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
variant: "ghost",
size: "sm",
onClick: () => pick(c),
}),
);
});
const tag = basisTag(c.근거);
if (tag && c.근거)
button.append(
el("span", {
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
text: tag,
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
}),
);
return button;
});
const basis = line.근거;
const source = basis
? [
el("div", {
className: "m02-hopyo__bar m02-hopyo__source",
children: [
note(`엑셀 근거 — ${sourceText(basis)}`),
createButton({
label: "엑셀에서 보기",
variant: "ghost",
size: "sm",
onClick: () =>
openSheet({
scope: opt.scope,
key,
파일,
canWrite: opt.canWrite,
단산행: basis.행,
}),
}),
],
}),
]
: [];
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
size: "sm",
@@ -385,6 +495,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
},
});
body.append(
...source,
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
el("div", { className: "m02-hopyo__cands", children: list }),
more,
@@ -411,6 +522,24 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
await remember(no, body);
}
/** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */
async function confirmBasis(no: number, lines: HopyoLine[]): Promise<void> {
const body: Record<string, HopyoMemory> = {};
for (const x of lines) {
const c = basisPick(x);
const m = x.맞춤;
if (!c) continue;
const g =
c === m.대상
? { 대상: c, 입력값: m.입력값, 환산: m.환산 }
: await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null);
const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null);
if (one) body[m.열쇠] = one;
}
if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning");
await remember(no, body);
}
async function build(no: number, fresh: boolean): Promise<void> {
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
@@ -7,6 +7,7 @@
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import "./M02_MasterTemplete_Labor_Diagram.css";
@@ -313,7 +314,7 @@ function mountShell(host: HTMLElement): State {
if (!e.ctrlKey) return;
e.preventDefault();
const r = viewport.getBoundingClientRect();
zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top);
zoomAt(st, wheelZoomsIn(e) ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); // ☰ [설정] 방향(332)
},
{ passive: false },
);
@@ -13,6 +13,7 @@ import { ROUTES } from "@config/config_frontend";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
@@ -238,6 +239,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
mountSettingsDrawer(user ?? {}, entry.open, entry.company); // ☰ 시스템 설정 바로가기(334)
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
+2
View File
@@ -38,6 +38,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR",
# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다.
# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다.
ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9"))
# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나
ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0"))
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
@@ -264,3 +264,21 @@
- 단위가 상이한 경우에는 우리의 단가산출 로직에 단위변환에 따른 결과 로직을 부여하여 맞출것
- 신규계획서를 작성이 필요하며 작성전 상세분석을 진행하여 코드의 영향을 분석을 진행.
6. 4번 전역 깃 싱크를 필히 한후 추가 검토 및 반영 업무 진행할 것.
224. (원문 · 10-15) 다름으로 이제 프로그램 개발 작업으로 돌아가자.
1. b05 b06의 공통 기능 중 하단 패널 분리가 있음. 여기에서 창분리를 추가로 하였으면 함. 종단 패널 전체에는 내부에 테이블, 유토곡선, 배수유역 이 보조 패널로 존재함. 이 중 유토곡선과 배수유역을 분리할 수 있게 반영. 버튼은 종단 그래프 좌측 상단의 버튼 양식 참고
2. b05의 계획노선 편집에서 계획노선 제안이나 기본 노선 사용자 제어기능 중 호의 사이에 존재하는 측점은 호의 중심에 존재해야한다함. 일종의 스냅기능이 필요함. 계획노선 편잡창에 측점 스냅 버튼을 추가하여 활성화 비활성화 할수 있게 반영.
3. 계획노선의 추천기능은 현재 서버에서 1분여간 걔산하여 보여줌. 이기능은 시점과 종점이 변경되거나 옵션값이 변경되지 않는다면 변경되지 않음. 사용자 편의성을 위해 미리 영구저장소에 값을저장하여 보여주고 옵션이나 시종점이 바뀐경우에만 신규추가할것. 기존 기본값들은 항상보관.
4. 계획노선의 결국 초기값은 예정노선을 따라가는 커브임. 이때 추천 선택지애 따라 연산한 결과들을 보여주고 적용할텐데 이때 추천노선 전체의 r값은 최소 12m 가 아니라 클수록 평면 계획노선이 좋음. 무조건 좋다라기 보다는 결국 현재노선 로직에서 추가로직으로 정리하는 방향이 좋을 수도 있음.
5. m02의 외부문서의 일위대가를 가져온 기능에 초기에 모달창으로 해당정보를 엑셀에서 열은것처럼 모달창에서 보여주고 사용자가 호표를 선택하면 영역을 하이라이트 하거나 한번에 불러오는 기능이 있으면 좋겠음.
6. m02의 외부문서에사 일위대가를 가져올 경우 이름이 많이 다름. 다른 외부문서에서도 동일함. 그러니 이름을 보고 매칭하는 기능도 좋지만 연결된 단가산출 탭의 계산식과 출처들을 참조한다면 정합성을 높일수 있을것같음.
7. 내장 캐드프로그램에는 오토캐드 프로그램을 참고하여 만들었음. 기능 중 명령어 중에 연속 명령어로 제어할 수 있는 기능이 필요. 예를들어서 산 단축키 입력> 각도지정> 길이입력 등임. 연속적으로 키보드로 작동하는 기능임. 이건 인터넷 조사하여 오토캐드에서 어떤식으로 제어기능을 제공하는지 분석필요함.
8. 우리 프로그램에는 마우스로 팬, 줌인아웃, 선택 기능들이 사용자가 제어할 수 있는 기능이 있음. 일체반영을 위해 햄버거 메뉴 안에 설정버튼을 추가하고 별도모달창에는 탭으로 구분하여 마우스 제어방법이나 오토캐드의 단축키, 다른 곳에서의 단축키 설정, 나중에는 페이지들 기본 옵션값들을 사용자가 조절할 수 있는 기능을 부여하려고 함. (기본값은 지금은 아님. 내가 별도 요청)
9. 신규 계획서를 작성하기 전 현재의 코드나 외부정보를 먼저 정밀 분석하고 진행. 신규계획서를 작성 후에는 업무를 분배하여 진행하고 너는 최종 완료시점에 사용자처럼 브라우져 제어하고 보고서를 아티팩트로 공유.
225. (원문 · 10-15) 추가수정 내용
1. 대시보드의 햄버거 메뉴에는 좌측에 프로젝트 내부의 진행단계처럼 바로가기 버튼을 추가하고 페이지 스크롤 추가
2. 시스템 설정컨테이너로 접근하는 4개의 페이지에는 햄버거메뉴에도 진행단계 처럼 바로 이동하는 버튼 추가
3.오토캐드의 프로그램이 추가 보강 입력방법이 없는지 확인.
4. 외부에서 일위대가 가져오기 기능에서 이름이 달라 매칭이 안되는 문제를 개선 방법이 있는지 확인.
5. 3번과 4번은 코드 요청이 아니라 상세검토 요청임. 코드 작업은 검토 결과 보고 판단할 예정
226. (원문 · 10-15) 3번 캐드항목에 대해 코덱스와 안티그래비티에게도 인터넷을 찾아보라고 하고 너도 추가로 더 찾아볼것.
4번 산림과 건설이 유일한 품셈정보이나 사용자들이 본인들의 편의대로 변경해서임. 우리 서버의 단가산출 로직이 정상이지만 우리서버를 바꾸더라도 같은 문제가 발생됨. 나는 우리서버측 단가산출 로직을 변경할 생각은 없음. 일위대가는 어차피 단가산출 로직의 조합이거나 일위대가일 수 밖에 없음. 그리고 중요한건 값이아니라 어떤 로직의 조합인지와 로직이 중요. 경우에 따라서는 한번 한 것들은 기억하는것도 좋음. 다시한번 정밀검토하여 정밀도를 높일 수 있는 방안 확보할 것. 3번/4번 재조사
@@ -0,0 +1,47 @@
# 325 유토곡선 · 배수유역 창분리 (요구 224-1)
기준 = 837fe3d64. B05 · B06 종단도는 한 벌(`B05_Profile_UI_Profile_Panel.ts:30` · `B06_Section_UI_Section_View_Profile.ts:277` → `sharedProfile`) — 한 번 고치면 두 보기에 같이.
## 지금 — 종단 [창 분할]
- 단추: `A00_Common/design/design_shared.ts:153,158` 빈 button → `toolLead` · `:181` `attachProfilePopout`(`design_profile_popout.ts:25-29`) · 글 「창 분할」/「창 결합」 · 툴팁 `title` · 클래스 `ui-btn ui-btn--ghost b05-route-profile__popout`.
- 열기: `window.open("", "aislo-profile-popout", "popup,width=1400,height=720")`(`:52`) · 막히면 토스트(`:54`) · 스타일 · html 속성 · body 배경 열 때 한 번 베낌(`:61-70`) · 종단 뿌리 DOM 을 새 창 body 로 옮김(`:71-73`).
- 동기화: 따로 없음 — 코드 · 가게 · sessionStorage 는 메인 것 · 같은 함수가 다른 창 DOM 을 그림(BroadcastChannel · postMessage 안 씀) → 고름 · 커서 · 확대 · 스크롤 저절로 맞물림.
- 이벤트: 새 창 keydown/keyup/pointerdown/pointerup/pointercancel · blur 를 메인 document 로 다시 쏨(`:14,76-90`) · 새 창 ResizeObserver → `profile.redraw`(`:93-96`).
- 되돌림: 새 창 pagehide → `slot.append` + 다시 그림(`:31-38,97`) · 메인 pagehide · 페이지 떠남(`design_page.ts:263` → `profile.dispose`) → 새 창 닫고 되돌림(`:39-49`).
- 화면 틀 · 시계 = 요소가 놓인 창 것(`common_util_view_window.ts:11-18` · 230-1/2) · 창 자리 · 크기 기억 없음(매번 1400×720) · 테마는 열 때만 베낌 · CSS `design_page.css:43-56`.
## 종단 안 보조 판 셋
| 판 | 만드는 곳 | 그리는 곳 | 접기 · 크기 |
|---|---|---|---|
| 표 | `design_profile_overlays.ts:61-63` · `_Profile_TableOverlay.ts` | `_Profile_Render.ts:173-199,409-411` | 손잡이 `table-open` · `table-height` |
| 유토곡선 | overlays `:48-59` → `B05_Profile_UI_Profile_MassHaul.ts:190`(손잡이 200-204 · 판 208-249) | `_Profile_Render.ts:415-458` → `MassHaul.draw :360-489` | `masshaul-open`(:220,264-279) · 높이 `masshaul-height`(:224-242) · 범례 `masshaul-visible`(:281-286) |
| 배수유역 | `design_profile.ts:181-199` `drainageSide`(`_Profile_SideTool.ts:11`) → `B05_Profile_UI_Drainage_Side.ts:26` → `_Drainage_Panel.ts:79` · 뼈대 `_Drainage_Chrome.ts:54-149` | 자체 캔버스 `_Drainage_Panel.ts:235-316` | `drainage-collapsed`(:556-567) · 폭 `drainage-width`(Chrome :139-147) |
- 배치: 뿌리 = [「종단」 손잡이][높이 끌기][content](`design_profile.ts:207`) · content = [bodyWrap][배수 판](`:198-199`) · 유토 · 표 판은 bodyWrap 바닥에 absolute 로 겹침(`_Style_MassHaul.css:54-66,168-180`) · 높이 나눔 `_Profile_Heights.ts:66-179` · 유토는 종단 본문 크기 0 이면 건너뜀(`_Profile_Render.ts:125`).
## 종단 그래프 좌측 상단 단추 줄
- DOM: `.b05-route-profile__balance`(28px · `_Style.css:533-547`) 맨 앞 `span.b05-route-profile__tools` 안 [창 분할] + `button.b05-route-profile__tool` 8개(초기화 ↶ ↷ 직선화 자유지정 쉬프트 ▲ ▼).
- 만드는 코드: `tools.render()`(`_Profile_Tools.ts:161-232` · 단추 함수 69-80) → `withToolLead`(`design_profile.ts:296-299`) → `renderBalanceBar`(`_Profile_Balance.ts:48-51`) · 그릴 때마다 새로.
- 양식: 글자 단추 · `title` 툴팁 · `--btn-bar-padding` · `--btn-bar-font-size`(`ui_template_theme.css:169-170` · 높이 16 · 글 12) · [창 분할] = ghost + `design_page.css:53-56`.
## 뗄 때 손볼 곳
1. 높이 나눔 · 끌기가 「`overlay.hidden` 거짓 = 열림」만 봄(`_Profile_Heights.ts:70` · `_Profile_Drag.ts:146,172,194`) → 떼어도 메인이 자리 비움 · 높이 끌기 놓으면 새 창 높이가 저장 높이로 굳음 → 「열림 && 뿌리 안」.
2. 유토 손잡이가 메인에 남음 → 뗀 동안 숨김.
3. 「같이 줄이기」 인라인 높이(`_Profile_Heights.ts:147`)가 새 창에 남음 → 뗄 때 비움.
4. 유토 세로 범위가 메인 보이는 구간을 따름(`_Profile_Render.ts:441,453-458`) → 새 창이 넓으면 잘림 → 뗀 동안 `viewRange` 안 씀(`MassHaul.ts:441`).
5. 배수 지도 크기 관찰 = 메인 창 ResizeObserver(`_Drainage_Parts.ts:468`) → 놓인 창 것으로 바꿔 끼우는 `rehome`(직전 크기 지켜 배율 유지).
6. 방향키 ←→ 는 마지막 누름이 `.b05-route-profile` 안일 때만(`B06_Section_UI_Cross_Panel_Base.ts:92`) → 선택자에 두 판 클래스.
7. 종단 새 창 안에서 다시 떼면 메인 `window.open` 이 팝업 차단될 수 있음 → 단추가 놓인 창으로 엶.
8. 창 이름은 조각마다 달리(`aislo-masshaul-popout` · `aislo-drainage-popout`) — 같으면 앞 창 body 를 덮어 조각이 사라짐.
9. 한계: 메인 종단 패널 접으면 뗀 유토 멈춤 · 유토 창으로 메인을 다 가리면 메인 화면 틀이 멈춰 늦음 · 토스트 · 좌측 폼은 메인 창.
700줄: `design_profile.ts` 700(시험 고정) · `design_view_profile.ts` 700 → 손대지 않음 · `_Drainage_Panel.ts` 700 → 한 줄 더하면 주석 합쳐 700. 다른 224 일감과 겹치는 파일 없음.
## 시험
- 있음: `test_282_1_profile_toolbar.py`(+ helper · 가짜 require 가 `showToast` 만 → popout 이 `viewOf` 를 가져오면 helper 도 고치거나 `button.ownerDocument?.defaultView ?? window`) · `test_187_1_b05_head_button_size.py`(`.b05-route-profile__tools > .b05-route-profile__popout {` 블록 정규식 → 새 단추 규칙은 따로) · `test_230_1_popout_wheel.py` · `test_230_2_popout_frames.py`(`POPOUT_FILES` 메인 창 rAF/setTimeout 금지) · `test_219_1_arrow_owner.py` · `test_design_profile_single.py` · `test_b05_drainage_side.py`(배수 파일은 `UI_Profile_` · `B06_Section` import 금지 · ≤700) · `test_281_1_drainage_zoom_range.py`.
- 새로: `test_224_1a_drainage_rehome.py`(+ cjs) · `test_224_1b_masshaul_docked.py`(+ cjs) · `test_224_1c_subpanel_popout.py`(+ cjs · 단추 자리 · 글 · 클래스 · 창 이름 셋 다름 · 열림/닫힘/dispose 제자리 · 손잡이 숨김/보임).
@@ -0,0 +1,35 @@
# 326 계획노선 편집 — 측점 스냅 (요구 224-2)
기준 = 837fe3d64.
## 해석
- 「호의 사이」 = BC~EC 누가거리 구간(서버는 곡선표 BC · EC 를 노선에 내려 구함 `B05_Profile/B05_Profile_Engine_Sections_Core.py:241-275` · 5 m 밖 곡선 버림 `:221`).
- 「호의 중심」 = 원 중심(노선 밖)이 아니라 곡선 중점 MC(IP 에서 내각 2등분선으로 외할장 SL · 누가거리로 BC~EC 한가운데 · `resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md:191`).
- 근거: 곡선부 BC · MC · EC 보조측점(`…/4. 노선측량.md:144`) · 「측점간의 번호는 20m이내에서 조정」(`:143`) · 교재 예시 표(`5. 설계.md:182-191`).
- 후보: (가) 호 안 20 m 측점을 MC 로 옮김 — 채택 · (나) 편집 손잡이를 IP 대신 MC 에(「측점」 말과 안 맞음) · (다) 노선을 움직여 MC 를 격자에(곡선 수십 곳 동시 불가).
- 실제 어긋남(저장소 자료 셈): 1078 m · 곡선 26 · R 12~199 → 호 안 측점 14곳 · MC 와 최대 11.3 m(호 616.1~646.5 · MC 631.3 에 620 · 640 둘) · 1153 m · 곡선 50(R 12 · CL ≤ 5.6) → 호 안 11곳(2.7 m 안) · 측점 없는 호 39곳. 측점 = 노선 모양과 상관없는 20 m 격자라 어떤 조작이든 같음.
## 지금
- 편집 창: `A00_Common/design/design_view_profile.ts:278-286` → `openRouteEditModal`(`B05_Profile/B05_Profile_UI_RouteEdit.ts:104-110`) · 단추 줄 `_RouteEdit_Chrome.ts:51-78`([노선 추천] | 분석 지형 · [1m 등고선] · [거리 재기] | 되돌리기 · 다시하기 · 초기화 · 취소 · 확인) · 켬끔 본보기 = [1m 등고선](`aria-pressed` + `is-active` `:63-65` · 배선 `_RouteEdit_Contour.ts:136-148`).
- 조작: 노드 끌기 `_RouteEdit_Pointer.ts:265-294` · 곡선 손잡이 BC · EC `:241-263` + `_RouteEdit_Curve.ts:89-109` · R · 곡선 길이 · 고정 `_RouteEdit_CurveBar.ts:61-116` · 두 번 = 노드 더함 · 오른쪽 = 지움(`RouteEdit.ts:494-512`) · 측점 눈금 누름 = 횡단 미리보기(`_Pointer.ts:186-201`) · 추천 [적용] `_RouteEdit_Initial.ts:132-160` → `RouteEdit.ts:428-434` · 기본 노선 `_ensure_planned_initial`(`B05_Profile_Router_Replan.py:339-379`) · [초기화] `_Initial.ts:164-197` · `Router_Replan.py:282-297`.
- 계산: 편집 중 TS 짝 `buildEditedPolyline`(`_Curve.ts:226-327`) · [확인] 서버 `replan`(`Router_Replan.py:580-669`) 전 체인.
- 곡선 자료: apex · radius_m · tangent_m · inner_angle_deg · start(BC) · end(EC)(서버 `common_util/common_util_route_polyline.py:104-141` · TS `_Curve.ts:32-42`) · 원 중심 셈만(`:130-152`) · MC 셈 코드 없음.
- 측점 정본 `generate_sections`: 20 m 격자 `_chainages`(`_Sections_Core.py:86-92,333`) · 관 · 구조물 0.005 m 안이면 격자에 얹음(`:338-357`) · 종류 bp/ep/irregular/regular(`:411-424`) · 곡선표는 확폭에만(`:368-385`) · BC · MC · EC 측점 안 심음 · 부르는 곳 둘 = `run_section_generation`(`B05_Profile_Engine_Sections.py:392-438`) · `sample_structure_sections`(`:513-551`) · 곡선표 읽기 `_load_design_curves`(`:71-96`).
- 편집 창 눈금: `drawStationTicks`(`B04_PreProcess_UI_MapOverlays.ts:225-260`) ← `_RouteEdit_Render.ts:461-482` · 같은 격자 따로 셈 = 누름 `_Input.ts:368-407` · 기울기 라벨 `_Slope.ts:54-60` · 종단 띠 `_ProfileStrip.ts:26-60` · 횡단 미리보기 서버 = 가장 가까운 격자(`Router_Terrain.py:172-184`).
- 측점 한 벌(272): `plantedChainages`(`common_util_structure_station.ts:235-242`) ↔ `resolve_extra_stations`(`Engine_Sections.py:250-275`) · `oneStationList`(`ts:254-274`) ↔ `_merge_irregular_into_longitudinal`(`Router_Confirm.py:295-343`) · `followMovedNodes`(`ts:276-298`) ↔ `_follow_moved_stations`(`Router_Confirm.py:204-292`) · 거울 시험 `test_272_1_station_list.py` · 정수 m = 구조물 · 관만 floor(x+0.5)(`common_util_structure_station.py:38-45` · `.ts:51-60`).
## 구현 자리 (짝 ① · 조작 중 TS · [확인] 서버 정본)
0. 쪼개기: `Router_Replan.py` 698줄 → 요청 모델 다섯(`:72-138`)을 `B05_Profile_Schema.py` 로(Replan 에서 다시 import · `replan.RouteInitialRequest` 그대로).
1. 짝 셈: `_Sections_Core.py` `snap_curve_stations(stations, spans)`(호 안 BC+0.005 < s < EC−0.005 측점 중 MC 에 가장 가까운 하나 → floor(MC+0.5) · 같은 m 하나로) · `generate_sections(..., station_snap=False)`(spans 셈을 앞으로 · 격자 붙임은 스냅 뒤 목록) ↔ `_RouteEdit_Frame.ts`(77줄 · Sections_Core 짝 · `snapCurveStations` · `curveSpans` · `stationsAlong` · import 없이).
2. 저장: 곡선표 `planned_route_curves.json` 에 `"station_snap"`([확인] 때만 · 작업본 · 스냅샷이 route 폴더째 복사 `common_util_initial_snapshot.py:39`) · 없으면 끔 · DB 옵션에 안 둠(`SectionGenerationOptions` 다시 짓는 곳 여섯) · `Router_Replan.py` 요청 칸 → `_write_planned_polyline`(`:300-336`) · GET `/route/plan` 돌려줌(`:558-577`) · 실패 되돌림(`:607-639`)에 `_curves.json` · `_nodes.csv` 도(기존 틈) · `Engine_Sections.py` `load_station_snap` → `run_section_generation` · `sample_structure_sections` 둘 다(한쪽만이면 [저장] 때 640 되살아남) · `Router_Terrain.py` 횡단 미리보기 `station_snap` + `design_curves=built.curves`.
3. 편집 창: `_RouteEdit_Chrome.ts` [측점 스냅](1m 등고선 양식 · [거리 재기] 옆) · `RouteEdit.ts`(635줄) 상태 = 메모리 모드(되돌리기 밖) · 열 때 GET 값 · `stations()` 한 곳 → 그리기 · 누름 · 기울기 · 횡단 요청 · [확인] `changed` + `station_snap` · 함께 `_RouteEdit_Render.ts` · `B04_PreProcess_UI_MapOverlays.ts`(`chainages` 선택 칸) · `_Input.ts`(`stationAtScreen` 선택 칸 · 라벨 판정 값으로) · `_Pointer.ts` · `_Slope.ts` · `_Apply.ts` · `B05_Profile_Api_Replan.ts` · 선택 칸은 뒤에(B04 지도 · 배수유역도 · `helper_b05_routeedit_pick.cjs` 그대로).
영향: B05 종단 · 3D · B06 · B07(`Router_Support_Basin.py:280-290`) · B08 = 서버 측점 목록 따라감 · 옮긴 측점의 B06 사용자 입력은 누가거리 정확 맞춤 이월이라 안 넘어감(`B03_FileInput_Service_Chain.py:574-586`) · 추천 비교값 · 종단 띠 균형 종단 = 20 m 격자 그대로 · B04 지도 · 배수유역도 눈금 격자 그대로(`_Drainage_Panel.ts` 700줄) · `common_util_route_polyline.py`(745줄) 손대지 않음.
## 시험
- 새로: `test_224_2_station_snap.py`(tsc 컴파일해 파이썬과 견줌 · 곡선 없음 · 측점 없는 호 · 하나 · 둘 · BC 위 · .5 올림 · 이웃 격자 같은 m · `generate_sections` 끔 = 지금과 같음 · 켬 = 640 없음 · 631 있음 · 631 에 관이면 `grid`).
- 있음: `test_74_route_confirm_initial` · `test_route_initial` · `test_64_1_profile_toggle` · `test_b05_routeedit_frame_mirror` · `test_b05_routeedit_pick` · `test_b05_routeedit_chrome_modal` · `test_b05_routeedit_cross_width` · `test_272_1_station_list` · `test_272_2_station_sync` · `test_238_1_save_keeps_structure_stations` · `test_273_2_preview_stations` · `test_b05_extra_stations_supplier` · `test_regeneration_extra_stations` · `test_b05_station_roundtrip` · `test_b06_curve_widening` · `test_75_profile_strip`.
@@ -0,0 +1,53 @@
# 327 · 328 계획노선 추천 — 영구 저장 · 큰 R (요구 224-3 · 224-4)
기준 = 837fe3d64. 경로는 저장소 뿌리 기준.
## 지금 — 추천 계산 길
- 화면: 편집 창 [노선 추천] → 모달 `B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts:132-162` · 알약 다섯(예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 · `_Recommend.ts:66-74`) · 열면 지금 갈래 한 번 셈 `:552-554` · 알약 = `run() :423-480` · 서버 갈래 여덟 중 safe · crossings · cost 숨김(`_Initial_Model.ts:439`) · [적용] = 편집값 + sessionStorage `routeedit-initial`(`_Initial.ts:151-159` · `_Initial_Model.ts:561-564`) · 서버 안 씀 · 편집 창 [확인] → `POST /route/replan`(`_RouteEdit_Apply.ts:86-101`) → 작업본 `planned_route.csv` · `_nodes.csv` · `_curves.json` + `B05_Profile/route/initial_choice.json`(갈래 이름) + initial_snapshot 다시 뜸(`B05_Profile_Router_Replan.py:607-663`).
- API: `POST /api/projects/{id}/route/initial`(`B05_Profile_Router_RouteInitial.py:27-126` · 화면 대기 5분 `B05_Profile_Api_RouteInitial.ts:15`) · 입력 = 예상노선 `B05_Profile/route/expected_route.csv`(없으면 initial_snapshot) `:59-61` · 최소 R `_plan_criteria :67` · 종단 상한 `:68-70` · 제약 칸(기울기 · 최소 R · 계류 이격 100 · 지반 고름 5 · 영선 목표) `:72-79` · 지형(확정 지표면 + 분석 지형) `:80-83` → 비용면 캐시 `B05_Profile_Engine_Solver.py:214-257` · 계류 `도엽_하천중심선.geojson` 소하천 이상(`Engine_RouteInitial.py:87-137`) · 관 지점 · mode · weights(50/25/25) · with_profile · design_speed.
- 차례(`generate_initial_route` `Engine_RouteInitial.py:212-350`): ① 예상노선 추적 = 전처리 초기값 규칙(0.4 초) ② 나머지 = 후보 묶음(입력 지문 `_pool_key :361-372` · 메모리 4벌 `_POOLS :108-110`) — 격자 탐색(예상노선 ±100 m 복도 · 4 m 칸 · 16방향 Dijkstra · `Search.py:236-301`) · 비용(이격 · 토공 · 기울기² · 영선 · 위험 · 계류 · 길이 · 꺾임 R·θ) · 후보(목표 7 × 상한 3단 + 쓸기 11 · `Pool.py:50-213`) · `_trace Pool.py:99-114` → `route_metrics Metrics.py:115-293` · 균형 종단(후보 전부 대충 → 고른 하나 바로 · ±60 m 횡단 · 계획고 ±6 m · B06 `compute_cross_design` · `Profile.py:66-402`).
- 1분 까닭: 같은 입력 첫 셈 35~80 초 · 같은 묶음 다른 갈래 3~5 초 · 묶음 짜기 1.9~2.7 초 · 대부분 = 후보 전부 균형 종단(63-4 때 1.8 → 18.3 초) · 메모리만이라 재시작 · 리로드 · 입력 바뀜마다 다시.
- 저장 없음: 남는 것 = 메모리 `_POOLS` · sessionStorage 한 벌 · `initial_choice.json`. 머리글 「파일 안 씀」 = `Engine_RouteInitial.py:6` · `Router_RouteInitial.py:33` · `Router_Replan.py:29` · `Profile.py:4-5`.
## 327 저장 설계
- 키: 시점 · 종점만으론 부족(추천은 예상노선 전체 씀) · 기존 `_pool_key`(지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 기울기 내용 해시) + mode · weights · with_profile · design_speed · `ALGO_VERSION`(코드 판 · 선례 `B04_PreProcess_Router_Watershed.py:82-87,227-242` `RESPONSE_SCHEMA_VERSION`) · 관 지점은 뺌([확인] 때마다 다시 냄 · 덮개 수는 꺼낼 때 다시 셈 `Profile.py:432-451`).
| 자리 | 판단 |
|---|---|
| `B05_Profile/route_recommend/<키>.json` | 씀 — initial_snapshot 대상(`common_util_initial_snapshot.py:38-43`) 밖이라 [초기화](`:154-159,260-274`)에 안 휩쓸림 · 자료 교체(`common_util_project_reset.py:23-54`) · 프로젝트 삭제 때 같이 지워짐 |
| `B05_Profile/route/` | 안 됨 — [초기화]가 되돌림 |
| initial_snapshot | 안 됨 — 쓰는 때 정해짐 · [확인]마다 통째 지움(`Router_RouteInitial.py:284` · `B03_FileInput_Service_Chain.py:449`) |
| DB 표 | 안 씀 — 마이그레이션 · 정리 목록까지 |
- 층 = 「서버 계산 캐시」(데이터 3층 밖 · 선례 비용면 npz `Solver.py:169-257` · 코리도 사전 생성 · 배수유역 응답) · 사용자 편집값 아님 → 자동저장 금지와 안 부딪힘.
- 꼴: 키마다 파일 하나 `{version, key, pool, default, created_at, request, result}` · 있으면 셈 안 함 · 없으면 셈해 더함(덮어쓰기 없음) · `default` = 빈 제약 칸 + 50/25/25 + 설계 지형 · 이 표시는 안 지움 · 한 벌 10~30 KB.
- 무효화: 지형 · 예상노선 · 계류 · 설정 바뀜 = 다른 키(옛 파일 남김) · 코드 바뀜 = `ALGO_VERSION` 올림 · 자료 교체 = 폴더째.
- 미리 셈: 체인 끝(`Service_Chain.py:225-227` 예상노선 · `:343-351` 스냅샷 뒤)에서 예약만(`_schedule_background_task` `B03_FileInput_Router_Helpers.py:279-299` · 체인이 [초기화] 요청 안에서도 불림 `B05_Profile_Router_Lifecycle.py:246`) · 다섯 알약 ≈ CPU 1~1.5 분 · 진행 중 같은 키 = 기다림.
## 328 큰 R
- R 정하는 자리: `common_util/common_util_route_polyline.py:670-717`(접선 자리 = 앞뒤 직선 짧은 쪽 절반 `:673` · 기본 최소 `:682` · 못박은 R `:683-685` · R 맞춤 `_fit_radius_m :358-391,686-697` = 최소 이상에서 예정노선과 평균 거리 최소 · 단순화 갈래에서만 · 같은 방향 꺾임 묶기 `:449-576`) · 추천은 예상노선 추적만 맞춤 · 나머지 `_trace` = 편집 갈래 → R = 최소 → **추천 곡선 전부 12 m**(자리 모자라면 더 작게 + 위반 `:698-716`).
- 12 m 출처: 설계속도 20 · 특수지형 `config/config_system_design.py:521-525` ← `B05_Profile_Engine_Grade.py:142-151` ← `Router_Replan.py:188-226`.
- 제약: 이웃 접선 겹침(절반씩) · 탐색 경로 이탈(외할 E = R(sec(IA/2)−1)) · 종단경사 · 계류(맞춤 뒤 다시 잼 · 위반 늘면 안 됨) · 토공(R 크면 확폭 줄어 토공 줄음 `Profile.py:127-151`) · 곡선 길이 하한(법정 없음 · 표시 5 m `config_system_design.py:548-557`).
- 방안 A(1단 · 「현재 노선 로직」 재사용): `_trace` 에서 탐색 경로를 예정노선처럼 폴리라인화 `build_planned_polyline(densify_route(nodes, 2 m), min_radius_m)` → 같은 단순화 · 묶음 · R 맞춤 · 최소 R 판보다 위반 많으면 최소 R 판 · 응답 꼴 같아 화면 `flattenServerPlan`(`_RouteEdit_Edits.ts:232-272`) 그대로 · +5~12 초.
- 방안 B(2단 · 「클수록」): A 곡선마다 R 을 [맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로와 최대 거리 ≤ 허용 이탈인 가장 큰 값 → 못박아(`simplify=False · radii`) 다시 짬(브라우저 짝 있음) · +1~3 초 · 허용 이탈 값은 사용자 확정.
- 방안 C(탐색 자체 큰 R): 층 수 ∝ R · 1.5~2.5배 느림 · 안 씀.
- 붙일 곳 `_trace` 한 곳 → 영선 · 토공 최소 · 기울기 최소 · 비중(+숨긴 셋) · 예상노선 추적은 그대로(`test_route_initial.py:102` · `Router_Replan.py:300-336` · 09-07 확정 「예정노선에 가장 가까운 R」).
- 손대지 않음: `common_util_route_polyline.py`(745줄 · TS 짝) · `Router_Replan.py`(698줄). 설명 정정: `B05_Profile_UI_RouteEdit_Recommend_Help.ts:32` 추적 「최소 R」 → 「맞춘 R」.
## 지식DB 근거 (값은 안 정함)
- 법정 최소곡선반지름(별표2 Ⅰ.2.다): 설계속도 40 → 60/40 · 30 → 30/20 · 20 → 15/12(일반/특수) · 중심선 기준 · 내각 155° 이상 곡선 생략 · 배향곡선 10 m — `resources/knowledge/original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_…(제5조제1항 관련).md:151-165`(산불진화임도 `:436-450`).
- 정리본 `resources/knowledge/technical_info/01_임도/02_상세설계/평면선형.md:17-72`(작업임도 규정 없음 · 확폭 · 45 m 이상 확폭 불요 · 곡선반지름 확보 우선).
- 「곡선반지름은 … 가급적 큰 것이 좋음」 — `original/임도기술교본/1. 교본의 개요/3. 용어해설.md:95` · 확보 우선 `3. 임도 계획과 설계/3. 실시설계.md:229,273`.
- 산식 · 표 `original/산림과임업기술(임도)/2. 임도/2. 임도구조.md:65-106`(R = ℓ²/4B · 20 m 목재 · 노폭 4 m → 25 m · R = V²/127(i+f) · 20 km/h 14~16 · 30 → 31~35 · 40 → 55~63) · 곡선부 종단 G − 80/R > S `:221-226` · K = R/I ≥ 3.0 `:235-248` · 예각 내각은 R 크게 못 둠 `:250`.
- 25 m 이하 = 25톤 대형차 곤란 · 구조개량 대상 — `original/행정규칙/임도설치 및 관리 등에 관한 규정/별표/별표3_임도구조 개량사업(제17조 관련).md:25,55,77`.
- 곡선 요소 식(TL · CL · SL · M · 접선 겹침) — `technical_info/01_임도/03_계산정보/곡선설치_계산.md:19-34`.
## 시험
- 추천: `test_route_initial.py` · `test_63_4_route_profile.py` · `test_77_recommend_widening.py` · `test_78_recommend_mass.py` · 화면 `test_b05_routeedit_initial.py`(+ cjs) · `test_b05_routeedit_initial_roundtrip.py`(+ cjs).
- R: `test_route_polyline*.py`(curve_fit · edit · apex_roundtrip · idempotent · handle_drag) · `test_user_values_survive.py` · `test_plan_curve_limits.py` · `test_b05_curve_radius_floor.py` · `test_initial_snapshot.py`.
- 새로: `test_224_3_recommend_store.py`(같은 입력 같은 키 · 하나만 바뀌어도 다른 키 · 두 번째 셈 0회 · 덮어쓰기 없음 · 스냅샷 복원 뒤 남음) · `test_224_4_recommend_radius.py`(R ≥ 최소 · 하나 이상 큼 · 위반 ≤ 최소 R 판 · 추적 그대로 · (B) 이탈 ≤ 허용).
@@ -0,0 +1,52 @@
# 329 · 330 외부 일위대가 — 엑셀 보기 창 · 단가산출 근거 맞춤 (요구 224-5 · 224-6)
기준 = 9edaec82e(324 끝). M02 = `M02_MasterTemplete/`.
## 지금
- 올리기: `Hopyo_Side.ts:38-72` → Router `:201-216` → `_upload :174-179` → `Excel.read_book` → `Store.save :72-87` — 읽은 결과만 `실무자료/HP-######.json`(원본 엑셀 안 남김).
- 본 화면: `Hopyo_View.ts:132-186` 호표 표 → `:207-268` 줄 표(11열) · 모달 = `choose() :359-394`(후보 5 + 다른 로직 찾기) 하나.
- 불러오기 길 둘 · 둘 다 호표 하나씩: ① build `:414-425` → `POST hopyo-build`(Router `:291-302` → `Build.py:191-204`) ② [로직 추가] `Labor_Detail.ts:496-512` → `Hopyo_Pick.ts:18-58`.
- 서버가 읽는 것: `Excel.py:69-97` 「일위대가」·「일위대가총괄표」 값만(xls formatting_info 없음 · xlsx read_only) → 호표 `{번, 이름, 규격, 단위, 금액}` · 줄 `{차례, 이름, 규격, 수량, 단위, 단가, 금액, 비고, 참조, 갈래}` · 검산. 격자 · 병합 · 열 폭 · 서식 · 행 번호 · 단가산출 시트 없음.
- xlrd `formatting_info=True`(북부청 .xls 0.05 초): 병합 11 · 열 폭 15열 · 행 높이 · 숫자 형식 `#,##0` · 가로 맞춤 · 테두리 · 굵게/채움 없음. xlsx 병합 · 폭은 openpyxl 비 read_only(무거움) → xlsx 는 값만.
- 크기: 북부청 일위대가 560행×15열(≈129 KB) · 단가산출 4,345행×6열(≈192 KB) · 거창 xlsx 단가산출 146열. 호표 행 범위 깔끔(제3호표 = 20–24행 · 68개 안 겹침).
## 329 엑셀 보기 창 — 부품
| 후보 | 근거 | 판단 |
|---|---|---|
| A00 산출근거 스프레드시트 `createSpreadsheet(host, book, {readOnly, tabs, onSelect})` | `A00_Common/spreadsheet/spreadsheet.ts:56-115` · Workbook `spreadsheet_types.ts:165-242`(병합 · 열 폭 · 행 높이 · 서식 · 틀고정 · 여러 시트) · M02 모달에서 읽기 전용 사용 중 `AreaCellPick.ts:49-118` | **씀** — 엑셀 모양 · 보이는 칸만 그림 · 늦게 불러옴 · 손잡이에 `select(범위)` 하나 더함(안쪽 `select :250-266` · `selectSpan` `spreadsheet_selection.ts:89` 있음) |
| B08·B09 자료 표 `ui_template_data_table.ts` | focusRow · rowClass | 병합 · 열 글자 없음 |
| 집계표 `createSheet` | 양식 문서용 | 안 맞음 |
## 330 근거 맞춤 — 자료
- 우리: 로직 1,469(돈 1,438) · 칸 = 키 · 원문번호 · 구분(산림품셈 · 건설품셈 공통/토목/건축/기계설비/유지관리) · 출처(「건설품셈 공통 3-2-1」) · 호표 줄 `{인력/재료/기계/로직, 요소 LB…, 단위, 수량식, 비목}` · 조항 묶음 `M02_Labor_Inputs.py:68-85` sources() · 인력 LB 378 · 기계 EQ 654.
- 엑셀: 일위대가 줄 비고 「단산N참조」 → 단가산출 B열 머리 「N.이름[단위]」(북부청 268/268 · 사방 139/139 이어짐) · 블록 안 출처 글(「산림사업표준품셈 9-14…」 「[건설표준품셈(3-1-3)]」 · 오타 「건설표줌품셈」) · 직종 줄(「보통인부 : 172,068 × 0.1인」 · 글자 사이 빈칸) · 출처 있는 블록 북부청 90/95 · 사방 24/67 · 직종 있는 블록 69/95 · 48/67.
- 지금 점수(`Match.py:297-325`): 두 글자 겹침 + 이름 같음 1 + 단위 0.15 + 산림 0.05 + 자재 규격 0.5 · 거름 `:307` 때문에 이름이 아주 다르면 후보에도 못 듦 · `_targets :215-256` 에 조항 · 직종 없음 · 북부청(기억 없음) 단가산출 268 = 같음 63 · 후보 200 · 없음 5.
| 단산 | 엑셀 이름 · 근거 | 지금 1순위 | 근거 1순위 |
|---|---|---|---|
| 93 | 구조물터파기(인력100%)(토사) · 건설 3-2-1 · 특별인부 | 구조물터파기 육상토사 GF000101 | GC000083 굴착(인력/토사) |
| 21·22·23 | 레미콘타설 · 건설 6-1-1 · 콘크리트공+보통인부 | 펌프카 타설 | GC000198 레디믹스트콘크리트 타설 |
| 64 | 비탈면보호공(평떼) · 건설 4-1-1 · 조경공+보통인부 | 비탈면 보양 | GC000143 잔디붙임 |
| 49 | 흙깍기(토사)(굴삭기1.0㎥) · 건설 3-2-3 | 없음(깍/깎) | GC000085 흙깎기(기계) 보통토사 |
함정: ① 건설품셈 번호에 부문 없음(잔토처리 3-1-3 ↔ 우리 유지관리 철골재 철거) → 공통 · 토목 덤 · 직종 · 낱말 하나도 안 겹치면 버림 ② 품셈 판 다름(산림 5-23-3 ↔ 우리 5-22-3) → 조항 같음은 덤 점수만 · 자동 확정 안 씀 ③ 보통인부는 겹침에서 뺌 ④ 직종 없는 기계 로직은 부문 덤이 가름.
## 위험 · 지킬 것
- 줄 수: Match 530 · View 485 · Router 312 · Excel 269 · Build 204 · Api 190 · Side 128 · Pick 58 · spreadsheet.ts 363 · `Labor_Detail.ts` 693(시험 고정 · 손대지 않음 · 덧줄은 Hopyo_Pick).
- 원본 읽기만: `resources/master_data/*.json` · `일위대가조합.json`(시험 master_hash).
- 길 충돌: `/hopyo/{key}/{no:int}`(Router `:228`) → 격자 길은 `/hopyo-grid/{key}`.
- `Match.py:464` 요약 `h[k]` → 옛 파일 KeyError → `.get`.
- 격자는 따로 파일 `실무자료/격자/<키>.json`(HP json 에 넣으면 목록 · 고르기가 통째로 읽음) · 지우기 때 같이.
- 이미 올린 파일 = 격자 · 근거 없음 → 다시 올려야 보임.
- `A00 spreadsheet.ts` 공용(고쳐도 됨) · 계약 `spreadsheet_types.ts` 안 고침.
- 기대값 바뀜: `test_m02_hopyo_match.py:106-108` 제3호표 구조물터파기 1순위 → 굴착(인력/토사)(의도).
- STmate 꼴(「일위대가표」·「단가산출근거」 · 실무 xlsx 6개)은 지금 「시트 없음」 400 — 이번 밖.
## 시험
- 있음: `test_m02_hopyo_excel.py` · `_match.py` · `_build.py` · `_screen.py`(+ `helper_m02_hopyo_pick.cjs`) · `spreadsheet/test_spreadsheet_d_input.py`(+ `helper_spreadsheet_d.cjs`).
- 새로: `test_m02_hopyo_grid.py`(병합 11 · 15열 · B20 제3호표 · `#,##0` · 행 범위 68 안 겹침 · [20,24] · 200/404 · 지우면 격자도 · 역할 · master_hash) · `test_m02_hopyo_basis.py`(블록 북부청 95 · 사방 67 · 단산93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · master_hash).
@@ -0,0 +1,66 @@
# 331 내장 CAD 키보드 연속 명령 (요구 224-7)
기준 = 837fe3d64. 경로 `B07_DesignDetail/openwebcad/` 아래 `src/…`.
## 지금 — 우리 코드
- 이미 됨: `L␣` → 첫 점 → `@100<30⏎`(한 줄 극좌표) · 마우스 방향 + `100⏎`(직접 거리).
- 안 됨: `<30⏎` → `100⏎`(각도 고정 뒤 길이 · `<30` 읽는 곳 없음 · 버려짐) · 이동 · 복사 · 회전은 클릭만.
- 버그: 시퀀스 도구(폴리선 등) 첫 점에 숫자 · `@x,y` → 기준점 없어 오류 → 「Error in tool actor」 · 도구 멈춤(`sequence-tool.ts:183-189,284-285` · `get-point-from-event.ts:26-28,42-44` · `state.ts:309-317`).
| 길 | 자리 |
|---|---|
| 키 | `src/inputController/input-controller.ts:92` keydown → `handleKeyStroke :340-448` · 글자 대문자로 `this.text` 에 쌓음 `:441-447` |
| 커서 옆 칸 | `draw :127-142` → `input-field-draw.ts:37-86`(안내 · 절대 x,y · 친 글 · 후보 6) |
| 명령줄 | `components/CommandLine.tsx:17-39`(↑↓ 이력 · Tab 완성 `:78-81`) · 기본 숨김 `components/Toolbar.tsx:23` |
| 단추 | `ribbon/Ribbon.tsx:172` · `components/QuickAccessBar.tsx:33` → `commands/run-command.ts:26-53` · 손가락 `input-touch.ts:122-140` |
| 이름 · 별칭 | `commands/commands.{draw,modify,annotate,layer,utility,insert,view,file}.ts` 153개(별칭 82) → `commands/registry.ts:27-57` · 해석 `:116-120` = 이름 → 별칭 → 접두사 첫 후보 |
| 상태 기계 | xstate 5.18.1 · 원본 기계 20(선 `tools/line-tool.ts:50-110`) + 우리 공장 `tools/factories/sequence-tool.ts:161-359`(약 100 명령) |
| 프롬프트 | `meta.instructions` → `state.ts:297-308` → 커서 칸 · `CommandLine.tsx:87` |
`handleEnterKey :474-599` 차례: 빈 Enter(대기면 마지막 명령 반복 `:483-486`) → TEXT_INPUT 단계면 글 그대로 `:498-508` → 명령 이름 `:509` → 숫자 `:516` → `x,y :531` → `@x,y :548` → `@d<a :565` → `d<a :576` → 나머지 TEXT_INPUT `:587`.
| 항목 | 지금 | 근거 |
|---|---|---|
| 절대 · 상대 · 극좌표 | 선 · 원 · 사각형 · 배열 · 그립 · 시퀀스 됨 · 이동 · 복사 · 회전 안 됨 · 각도 동쪽 0° 반시계 + | `input-patterns.ts:7-17` |
| 직접 거리 | 선 · 폴리선 · 원 · 사각형 · 시퀀스 됨 · 이동 · 복사 안 됨 | `get-point-from-event.ts:25-37` |
| 각도 고정 `<30` | 안 됨 | `:587-597` |
| 동적 입력 | 안내 + 절대 좌표 + 친 글만 · 길이/각도 칸 · Tab 없음 · F12 = 개발자 도구 | `:127-142` · `:349-352` · `:384-387` |
| 직교 F8 · 극 추적 F10 | 90°/45° 가이드에 가까울 때만 붙음 · 각도 간격 없음 · F3 끄면 가이드 계산도 꺼짐 | `:369-383` · `helpers/get-draw-guides.ts:36-49` |
| 객체 스냅 | 끝 · 중간 · 교차 · 중심 · 사분 · 정렬선 됨 · END/MID 한 번 재정의 없음 | `helpers/calculate-angle-guides-and-snap-points.ts:21-59` |
| Enter · Space · 우클릭 · Esc | Space = Enter(글 단계 빼고) · 우클릭 = Enter(반복 안 함) · Esc = 선택 해제 → 글 지움 → 취소 | `:419-428` · `:150-156` · `:450-463` |
| 선 중 `U` · `C` | 안 됨 — `U` = 전체 UNDO 실행 · `C` = 원 명령 실행 | `commands/commands.file.ts:100-107` · `commands.draw.ts:51-58` |
| 이동 · 복사 · 회전 값 | 클릭만 · 축척만 배율 숫자 | `tools/move-tool.ts:146-169` · `copy-tool.ts:146-168` · `rotate-tool.ts:146-187` · `scale-tool.ts:178` |
| 숫자 형식 | `100` · `1.5` 만 · `-5` · `.5` 버려짐 | `input-patterns.ts:4` |
| 안내문 | 원본 도구 영어 · 우리 시퀀스 한글 · `[닫기(C)]` 같은 선택지 안내 없음 | `line-tool.ts:61,80` |
넣을 자리: 공통 점 계산 `getPointFromEvent`(`get-point-from-event.ts:18-49`) — 선 · 원 · 사각형 · 배열 · 그립 · 블록 · 시퀀스가 모두 거침 → 각도 고정을 여기에 넣으면 한 번에 퍼짐 · 안 거치는 곳 = 원본 이동 · 복사 · 회전(`move-tool.ts:194-199,251` · `copy-tool.ts:193-198,248` · `rotate-tool.ts:206-220,264`) · 미리보기가 마우스를 직접 읽는 곳 = `line-tool.ts:134` · `sequence-tool.ts:143-148,179`. 주의: 선택지(C/U)용 TEXT_INPUT 을 점 단계에 달면 숫자 · 좌표까지 글로 넘어감(`:498-508` 이 숫자 해석보다 앞) → 자유 글 단계(`ime-sink.ts:19-24`)만 먼저 보내게 차례 바꿈.
키 충돌: CAD = 같은 출처 iframe(`ui_template/cad_host/cad_host.ts:143-156`) · B07 페이지 keydown 없음 · 부모 칸을 누른 뒤엔 키가 CAD 로 안 감 · F5 · F11 · F12 · Tab · Ctrl+V 만 브라우저로(`:349-391`) · `<` `@` 숫자 `,` `.` `-` Enter Space 충돌 없음 · Tab 가로채면 초점 이동 뺏음 · 한글 자판 = 점 단계에서 IME 칸 초점 풂(`ime-sink.ts:72-81`). 같은 CAD 를 B06 횡단 상세 · B07 평면도 관리 · M02 도면도 씀(`createCadHost`) → 넷에 함께 반영.
반영: `npm --prefix B07_DesignDetail/openwebcad run build`(`tsc -b && vite build`) → `dist/` → FastAPI `/b07-cad`(`main.py:531-552`) → 새로고침만. 시험 틀 = vitest(`src/inputController/ime-sink.test.ts:58-140` · `test/helpers/init-application.ts` · `test/helpers/click.ts`) · `resources/tester/cad/`(ctrl-shortcut-hangul · grips · clone-keeps-look · geometry · 실행 `cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad <이름>`) · 키보드 좌표 · 거리 시험 0건.
규모: src 228 파일 ≈27,800 줄 · 700 넘음 `App.css` 1,102 · 가까움 `screenCanvas.drawController.ts` 691 · `state.ts` 647 · `MeasurementEntity.ts` 622 · `input-controller.ts` 620. 원본 OpenWebCAD(MIT · 77ab85739 들여옴 · 원본과 연결 없음 → 원본 파일 고쳐도 됨).
## 조각 (코드 조사 제안)
1. 각도 고정 `<각도` → 길이 — 새 `src/inputController/input-angle-lock.ts`(고정 저장 · `lockPoint(base, p)` · 안내) · `input-patterns.ts` `ANGLE_LOCK_REGEXP` · `input-controller.ts` `:509` 앞 분기 · draw 안내 줄 · Esc 풂 · `get-point-from-event.ts` 고정 중 미리보기 · 클릭 · 숫자를 고정 방향으로 · 한 점 쓰면 풂 · `line-tool.ts:134` · `sequence-tool.ts:179` 미리보기 · `run-command.ts:40` 새 명령이면 풂 — 확인 = `resources/tester/cad/keyboard-chain.test.ts`(`0,0` · `<30` · `100` → (86.603, 50) · `<90` · `50` → (86.603, 100) · 고정 없이 `100` = 마우스 방향).
2. 시퀀스 첫 점 버그 — `sequence-tool.ts:284-285` NUMBER · RELATIVE 에 `!!lastPoint(context.values)` 조건.
3. 이동 · 복사 키보드 값 — 시작 ABSOLUTE · 끝 NUMBER · ABSOLUTE · RELATIVE · `getPointFromEvent` 로.
4. 회전 각도 숫자 — `rotate-tool.ts:156-174` NUMBER_INPUT · 음수 허용(`input-patterns.ts:4`).
5. (선택) 선 중 `C` · `U` · 6. 원본 안내문 한글화 + 「(길이 · <각도 · @x,y)」 · 7. 동적 입력 `100 Tab 30⏎`.
## 오토캐드 방식 (인터넷 조사 · Autodesk 도움말 우선)
- 네 겹: 명령줄(글자) → 프롬프트 대기(점 · 값 · 선택지) → 점 입력 문법(`x,y` · `@` · `<` · 숫자만) → 보조 모드(직교 · 극 추적 · 스냅). 프롬프트 하나가 클릭 · 좌표 · 거리 · 선택지 글자 · 스냅 이름을 다 받음.
- 명령줄: 이름 · 별칭(acad.pgp `A, *ARC` · 대소문자 무관) → Enter/Space · 자동완성(자주 쓴 순 · 중간 글자 · 지연 300ms) · ↑↓ 최근 입력 · 끝난 뒤 Enter/Space = 직전 명령 · Esc 취소 · 우클릭 = Enter/메뉴(시간 감지 짧게 Enter · 길게 메뉴) · 투명 명령 `'ZOOM`.
- 프롬프트: `Specify next point or [Close/Undo]:` · 대괄호 = 선택지 · 대문자 글자 = 입력 키 · `<값>` = 기본값(Enter 면 유지) · 선택지 클릭 · 동적 입력 ↓ 목록.
- 점 입력: 절대 `100,200` · 상대 `@30,0` · 극 `30<45` · 상대 극 `@30<45` · 각도 잠금 `<45` Enter → 커서로 쪽 → `30` Enter(다음 한 점만 · "Angle Override") · 직접 거리(커서 방향 + `30` Enter · 직교 · 극 추적과 같이) · 각도 = 동쪽 0° 반시계 + · 도분초 `45d30'15"` · 방위 `N45d30'E` · 소수점 `.` 만(`,` = 좌표 구분).
- 동적 입력(F12): 둘째 점부터 기본 상대 극(`@` 생략) · 절대는 `#` · 치수 입력 = 길이 · 각도 칸 · 값 넣고 Tab = 그 칸 잠금 + 다음 칸 · Enter 확정.
- 보조 모드: 극 추적 F10(증분 90 · 60 · 45 · 30 · 22.5 · 18 · 15 · 10 · 5° · 추가각) · 직교 F8(Shift 누르는 동안 반전) · 객체 스냅 F3 + 점 프롬프트에서 END · MID · CEN · INT · PER · TAN · NEA · QUA · NOD(다음 한 점만) · NON · 스냅 추적 F11 · 임시 덮어쓰기 Shift+키.
- LINE 「선 → 각도 → 길이」 키 차례: 동적 입력 켬 = `L` Enter → 첫 점 → Tab `45` Tab(각도 잠김) `30` Enter 또는 `30` Tab `45` Enter 또는 `30<45` Enter · 끔 = `L` Enter → 첫 점 → `<45` Enter → 커서로 쪽 → `30` Enter 또는 `@30<45` Enter · `U` Enter = 마지막 선 취소 · `C` Enter = 첫 점으로 닫고 끝 · Enter · Esc = 끝 · 새 LINE 첫 점에서 Enter = 마지막 끝점에서 이어 그림.
- 비슷한 프로그램: BricsCAD(같은 명령줄 · 동적 치수 길이 → Tab 잠금 → 각도 · F12 = Quad) · DraftSight(Quick Input F12 · Tab) · LibreCAD(`@100<90` · `@` 없으면 절대) · nanoCAD(`300` Tab `30` Enter) · Onshape(명령줄 없음 · 그린 뒤 치수 구속) · AutoCAD Web(명령줄 · 선택지 단추).
- 웹 함정: 브라우저 F키(F1 · F3 · F5 · F6 · F7 · F10 · F11 · F12 · 막을 수 있는 건 캔버스 초점 때 preventDefault · 실측 필요) · Ctrl+W · T · N 못 막음 · 한글 IME(key "Process" · keyCode 229 · `event.code` 우선 · 조합 중 Enter 무시) · y축 아래(내부 수학각 하나 · 화면에서 뒤집기) · 우클릭 · 가운데 누름 기본 동작 막기.
- 사용자 지정(설정 모달 후보): 별칭 편집(acad.pgp User Defined · 같은 별칭은 마지막이 이김) · 단축키(CUI) · 우클릭 모드 · 시간 · 휠 줌 방향(ZOOMWHEEL 0 = 앞으로 굴림 확대 · 1 = 반대) · 비율(ZOOMFACTOR 60) · 가운데 끌기 = 팬(MBUTTONPAN 1) · 동적 입력 기본(상대/절대 · 극/직교) · 극 추적 각 · 각 형식.
- 추천 사양: 1단계 = 명령줄 · 별칭 + 자동완성 · 프롬프트 `[닫기(C)/명령취소(U)] <기본값>` · 점 파서 한 벌(`x,y` · `@dx,dy` · `d<a` · `@d<a` · `<a` · 숫자 · 음수 · 동쪽 0° 반시계) · LINE U · C · 첫 점 Enter 이어 그림 · 직교 · 한 번 스냅 이름 · IME · 우클릭 = Enter · 2단계 = 동적 입력 길이 Tab 각도 · 극 추적 · Shift 직교 · 최근 입력 · 3단계 = 스냅 추적 · FROM · 자동교정 · 설정 모달(별칭 · 단축키 · 우클릭 · 휠 · 동적 입력 기본 · 각 형식).
- 출처(대표): 명령줄 help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-BB0C3E79-66AF-4557-9140-D31B4CF3C9CF.htm · 각도 잠금 · 직접 거리 …/2027/ENU/AutoCAD-LT/files/GUID-57CDDB6C-B12B-46CE-B9C5-22EFC17258FF.htm · 극좌표 …/2018/ENU/AutoCAD-LT/files/GUID-58C1D97C-A9B3-4C3C-AC1B-95BE3DF2EDB9.htm · 동적 입력 …/2027/ENU/AutoCAD-LT/files/GUID-3EBD4C17-F0A5-49FA-B131-4AABE2E727DB.htm · 치수 입력 …/2021/ENU/AutoCAD-LT/files/GUID-A265C30C-CFD8-405D-BEE2-257B379475E2.htm · 극 추적 …/2027/ENU/AutoCAD-LT/files/GUID-7EC3C63D-EA4E-4E65-A676-C3A3627E3F19.htm · 객체 스냅 …/2018/ENU/AutoCAD-LT/files/GUID-8F5E5431-9EFB-414E-BC6D-2C65EFB2DAC3.htm · 별칭 편집 …/2025/ENU/AutoCAD-Customization/files/GUID-D4CACED6-DFBA-43C3-BC42-8D980AB3AE75.htm · 우클릭 …/2027/ENU/AutoCAD-LT/files/GUID-7C6AE349-DDF0-40DF-9D1E-FA9702BE88BB.htm · 휠 · 가운데 …/2018/ENU/AutoCAD-LT/files/GUID-B1CDEE41-F8BF-48A4-A895-273DBB295378.htm · Chrome 단축키 support.google.com/chrome/answer/157179 · KeyboardEvent.code developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code. 약한 근거: AutoCAD Web · DraftSight 원문 503(검색 요약) · 별칭 표는 옛 acad.pgp 사본(공식 확인 L · C · E · LA) · F키 가로채기 실측 필요.
@@ -0,0 +1,64 @@
# 332 ☰ [설정] 모달 — 마우스 · 단축키 (요구 224-8)
기준 = 837fe3d64.
## ☰ 서랍 · 모달 · 탭
- 서랍 한 벌: `A00_Common/app_shell.ts:149-210` `buildDrawer` · 차례(`:181-208`) = 로고 · 진행 단계(작업 흐름 화면만 · `ui_template/ui_template_workflow_layout.ts:203`) · 메뉴 5(`:127-133`) · 설정 묶음(`:205-207` = 언어 · 테마 `:190-201` · 계정 `:250-299` · 터치 모드 `:212-247`) · 주소 바뀌면 다시 그림(`main.ts:78`) · 권한 갈래 없음 · 닫힘 = 로고 · 단계 · 메뉴 · 계정 누름(`:175-179`) · Esc(`:173`) · 바탕(`:172`).
- 더하는 법: 설정 묶음(`:207`)에 `createButton` 한 줄 · 문구 `ui_template/ui_template_locale_common.ts:103-111` · 누르면 닫히려면 `:177` 선택자에 클래스.
- 모달 `ui_template/ui_template_modal.ts:12-62` `openModal`(actions · wide · beforeClose · z 1000 > 서랍 900) · 저장 안 한 고침 물음 `ui_template/ui_template_leave_dialog.ts:13` `askLeave`(예 `B08_Quantity_UI_Drawing.ts:210`).
- 탭 공용 부품 없음 · 공용 모양 `.ui-subtabs`(`ui_template_general_layout.css:58-70`) + `createButton` filled/ghost + `role=tab`(예 `B08_Quantity_UI_Milestone.ts:180-200` · `B09_Estimation_UI_TabBar.ts:30-60` · `M02_DF_InputPanel.ts:144-155`).
## 마우스 현황 (당김 = 휠을 몸 쪽으로 굴리면 확대)
| 화면 | 왼쪽 | 가운데 | 오른쪽 | 휠 | 근거 |
|---|---|---|---|---|---|
| B04 2D 지도 | 고름 · 관 끌기 | 팬 | 메뉴 | 줌 · 당김 | `B04_PreProcess_UI_MapViewer.ts:536-625` |
| B04 · B05 3D | 회전 · 고름 | 팬 | 배치 메뉴 | 줌 · 당김 | `B04_PreProcess_UI_Camera.ts:140-254` · `B05_Profile_UI_Viewer.ts:145,266` |
| B05 배수유역도 | 관 끌기 · 고름 | 팬 | 메뉴 | 줌 · 당김 | `B05_Profile_UI_Drainage_Interact.ts:60-119` |
| B05 노선 편집 지도 | 노드 끌기 · 두 번 = 더함 | 팬 | 노드 지움 | 줌 · 당김 | `B05_Profile_UI_RouteEdit_Input.ts:49-146` |
| 노선 편집 횡단 · B06 [상세] | 고름 | 팬 | — | 줌 · 당김 · 두 번 = 원배율 | `A00_Common/b_svg_zoom_pan.ts:162-264` |
| B05 · B06 종단 · 유토 · 측점표 | 가로 이동 | 가로 이동 | 메뉴 | 가로 스크롤 · 확대는 단추 | `B05_Profile_UI_Profile_Wheel.ts:82-177` |
| B06 횡단 카드 | 고름 | 팬 | 메뉴 | 목록 스크롤 | `B06_Section_UI_Cross_View_Zoom.ts:51-100` |
| CAD(B07 · B06 상세 · M02 도면) | 고름 · 점 | 팬 | Enter | 줌 · 당김 | `openwebcad/src/inputController/input-controller.ts:150-300` · `input-wheel.ts:20-23` |
| B08 거리이정표 | 빈 곳 끌기 = 팬 | 팬 | — | 줌 · 당김 | `B08_Quantity_UI_Milestone_Diagram.ts:157,463-474` |
| B08 · B09 표 | 고름 · 두 번 = 입력 | — | 메뉴 | 스크롤 | `ui_template/ui_template_data_table.ts:514-529` |
| M02 DF 그림 | 상자 끌기 · Space+왼쪽 = 팬 | 팬 | — | 줌 · 밀기 | `M02_DF_Diagram.ts:57-66,453-476` |
| M02 노무 그림 | Space+왼쪽 = 팬 | 팬 | — | Ctrl+휠 · 밀기 | `M02_MasterTemplete_Labor_Diagram.ts:283-332` |
| M02 시트 | 칸 고름 | — | 메뉴 | Ctrl+휠 · 밀기 | `A00_Common/spreadsheet/spreadsheet_grid.ts:270-284` |
| 공용 돋보기 | — | — | 0.4초 누름(Z 키도) | 배율 · 밀기 | `ui_template/ui_template_loupe.ts:5-93` |
- 팬 = 가운데 단추로 통일 · 휠 방향 둘로 갈림 · 공용 판정 `A00_Common/touch_bridge.ts:35-36` `wheelZoomsIn` 은 셋만 씀(B04 지도 · 배수유역도 · 노선 편집 지도) · 나머지 직접 박음(`b_svg_zoom_pan.ts:217` · `B04_PreProcess_UI_Camera.ts:253` · `input-wheel.ts:23` · `M02_DF_Diagram.ts:457` · `Labor_Diagram.ts:316` · `ui_template_loupe.ts:91` · `spreadsheet_grid.ts:274`).
- 지금 사용자가 바꿀 수 있는 것: 터치 모드(localStorage `frd_touch_mode` · `device_context.ts:33,91-101`) · B05 3D 투영 · B09 행 누름(pref `b_page_state.ts:71,107`) — 마우스 단추 · 휠 방향 칸 없음. CAD 휠 지수 · 스냅 거리 상수(`openwebcad/src/App.consts.ts:52,150-160`) · 토글은 메모리(`state.ts:82,179-180`). maplibre 안 씀(평면 = 자체 캔버스).
## 단축키 현황
| 자리 | 키 | 근거 |
|---|---|---|
| 공용 판정 | Ctrl+Z · Shift+Z · Y · S · A(한글 자판 자리) | `A00_Common/key_shortcut.ts:28-49` |
| 전역 | Esc = ☰ · 창 닫힘 · 고름 풂 · Z 누름 = 돋보기 | `app_shell.ts:173` · `ui_template_modal.ts:53` · `design/design_pick_escape.ts:15-29` · `ui_template_loupe.ts:66-72` |
| B05 종단 | Ctrl+Z · Ctrl+Shift+Z · 방향키 · 자유지정 Enter · Esc · Backspace | `B05_Profile_UI_Profile_Panel_Tools.ts:310-349` |
| B05 노선 편집 | Ctrl+Z · Y · Shift+Z | `B05_Profile_UI_RouteEdit_History.ts:157-165` |
| B06 | 방향키 · ▲▼ · Enter/Space · Esc | `B06_Section_UI_Cross_Panel_Base.ts:264-290` · `_Cross_Design_Pick.ts:545-562` |
| CAD | F3 · F7 · F8 · F10 · Ctrl+Z · Y · A · S(M02) · V · Delete · Esc · Enter · Space = Enter · 방향키 · 글자 = 명령 | `input-controller.ts:364-447` |
| CAD 별칭 | 명령 153 · 별칭 82 · `commands.*.ts` `aliases` | `openwebcad/src/commands/registry.ts:50-57,116-135` |
| B08 · B09 · 표 | Enter · F2 · 방향키 · Esc | `ui_template_data_table.ts:520-529` 외 |
| M02 | Ctrl+S · Z · C/V · Delete · Esc · Space = 팬 · 시트 엑셀 키 | `M02_MasterTemplete_CardShell.ts:79-80` · `spreadsheet_keys.ts` |
정의 표 = CAD 별칭 하나(엑셀 키 표 `spreadsheet_shortcuts.ts:1-10` 은 대조용) · 충돌 관리 없음(입력 칸 건너뜀 · `defaultPrevented` 양보 · 창 떠 있으면 양보 · CAD 는 iframe · 별칭 겹치면 먼저 것 `registry.ts:53-56` · 이름 > 별칭 `:119`).
## 저장 — 새 표 · API 필요 없음
- 표 `user_ui_prefs`(`db_management/018_user_ui_prefs.sql:17-25`) · `GET/PUT /api/dashboard/me/ui-prefs`(`B01_Dashboard_Router.py:122-133` · 값 문자열 · 통째 덮음 `Repository.py:554-573`).
- 브라우저 `pref` 통 = localStorage `aislo:pref:*`(`A00_Common/b_page_state.ts:47-112,285-287`) · 바뀌면 1.5초 모아 PUT(`:337-375`) · 로그인 첫 세션에 서버 값 얹음(`A06_Login_Api_Fetch.ts:64-69` → `b_page_state.ts:378-404`) · CAD iframe 같은 오리진 → localStorage 같이 씀(테마 선례 `openwebcad/src/theme.ts:43-52`).
## 설계 후보 · 위험
- 읽기 층 새 `A00_Common/input_prefs.ts`(의존 없음 · CAD 도 import) — `INPUT_PREFS_KEY` · `DEFAULT_INPUT_PREFS`(= 지금 동작) · `inputPrefs()`(부를 때마다 읽음 · 깨짐/Node = 기본값) · `wheelIn(deltaY)` · 모달 [저장] 때만 `writeState("input-prefs", 값)`.
- 배선 자리: 휠 = `touch_bridge.ts:36`(셋 자동) · `b_svg_zoom_pan.ts:217` · `B04_PreProcess_UI_Camera.ts:253` · `input-wheel.ts:23` · (일체면) M02 둘 · 돋보기 · 시트 · CAD 별칭 = `registry.ts:116-131`(이름 > 내 별칭 > 기본) · CAD 오른쪽 = `input-controller.ts:155`.
- 위험: 700줄(`B04_PreProcess_UI_MapViewer.ts` 699 안 만짐 · `M02_DF_Diagram.ts` 692 한 줄 · `input-controller.ts` 620) · 공용 파일(`b_page_state.ts` · `app_shell.ts` · `touch_bridge.ts` · locale) 한 창이 맡음 · CAD `input-controller.ts` · `registry.ts` 는 331 과 겹침 → 331 뒤 · 휠 일체면 M02 그림 · 시트 · 돋보기 기본 방향 반대 · 계정 전환 시 앞 사람 값 남을 수 있음(기존 한계) · Node 시험(helper_164 · 266 등)이 `b_svg_zoom_pan` · `touch_bridge` 를 불러 돌림 → `input_prefs` 는 불러올 때 window 안 만짐 · CAD 명령 목록은 `commands.*.ts` import 시 xstate 딸려 옴 → 목록 JSON 한 벌 + 거울 시험.
## 시험
- 있음: `test_device_drawer.py`(서랍 차례) · `test_device_audit.py` · `test_touch_bridge.py` · `test_cad_touch.py` · `test_prefs_persist_across_tabs.py` · `test_b01_ui_prefs_dead_project.py` · `test_session_keys_registered.py` · 휠 · 팬(`test_164_detail_zoom.py` · `test_266_detail_home.py` · `test_281_1_drainage_zoom_range.py` · `test_230_1_popout_wheel.py` · `test_244_6/8_*` · `test_b05_routeedit_cross_width.py` · `test_cross_axes_zoom.mjs` · `test_cross_pan_bounds.mjs` · `openwebcad/src/inputController/input-wheel.test.ts`) · 돋보기 · 키 · 모달(`test_218_1_loupe.py` · `test_220_1` · `test_221_1` · `cad/ctrl-shortcut-hangul.test.ts` · `cad/ctrl-shortcut-sheet.test.ts` · `test_modal_head_actions.py` · `test_cad_host_tabs.py`).
- 새로: `test_input_prefs.py`(+ cjs) · `cad/input-prefs-cad.test.ts` · `test_app_settings.py`.

Some files were not shown because too many files have changed in this diff Show More