Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티

This commit is contained in:
2026-10-16 06:29:50 +09:00
344 changed files with 83515 additions and 4498 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: "push", 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 === "pull" ? "pull" : "push",
cadAliases: cleanAliases(value?.cadAliases),
};
} catch {
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
}
}
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
export const wheelIn = (deltaY: number): boolean =>
inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0;
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
export const isTrackpadPinch = (e: Pick<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": ["ED", "TEDIT"]},
{"id": "FIND", "label": "찾기·대치", "aliases": []},
{"id": "DIM", "label": "치수", "aliases": []},
{"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]},
{"id": "DIMALIGNED", "label": "정렬 치수", "aliases": ["DAL"]},
{"id": "DIMANGULAR", "label": "각도 치수", "aliases": ["DAN"]},
{"id": "DIMARC", "label": "호 길이 치수", "aliases": ["DAR"]},
{"id": "DIMRADIUS", "label": "반지름 치수", "aliases": ["DRA"]},
{"id": "DIMDIAMETER", "label": "지름 치수", "aliases": ["DDI"]},
{"id": "DIMORDINATE", "label": "세로좌표 치수", "aliases": ["DOR"]},
{"id": "DIMBASELINE", "label": "기준선 치수", "aliases": ["DBA"]},
{"id": "DIMCONTINUE", "label": "연속 치수", "aliases": ["DCO"]},
{"id": "QDIM", "label": "빠른 치수", "aliases": []},
{"id": "CENTERMARK", "label": "중심 표식", "aliases": []},
{"id": "CENTERLINE", "label": "중심선", "aliases": []},
{"id": "DIMSTYLE", "label": "치수 스타일", "aliases": ["D"]},
{"id": "DIMUPDATE", "label": "치수 업데이트", "aliases": []},
{"id": "DIMSPACE", "label": "치수 간격", "aliases": []},
{"id": "MLEADER", "label": "다중 지시선", "aliases": ["MLD"]},
{"id": "MLEADERSTYLE", "label": "지시선 스타일", "aliases": ["MLS"]},
{"id": "MLEADERALIGN", "label": "지시선 정렬", "aliases": []},
{"id": "TABLE", "label": "테이블", "aliases": ["TB"]},
{"id": "TABLESTYLE", "label": "테이블 스타일", "aliases": ["TS"]},
{"id": "TABLEEDIT", "label": "칸 편집", "aliases": []},
{"id": "TABLEROW", "label": "행 넣기·지우기", "aliases": []},
{"id": "TABLECOL", "label": "열 넣기·지우기", "aliases": []},
{"id": "TABLEMERGE", "label": "칸 병합", "aliases": []},
{"id": "TABLEUNMERGE", "label": "병합 해제", "aliases": []},
{"id": "PLACETABLE", "label": "표 놓기", "aliases": []},
{"id": "REVCLOUD", "label": "구름형 리비전", "aliases": []},
{"id": "ANNOSCALE", "label": "주석 축척", "aliases": []},
{"id": "LAYER", "label": "도면층 특성", "aliases": ["LA"]},
{"id": "LAYCURSET", "label": "현재 도면층 설정", "aliases": []},
{"id": "LAYOFF", "label": "도면층 끄기", "aliases": []},
{"id": "LAYON", "label": "모두 켜기", "aliases": []},
{"id": "LAYFRZ", "label": "동결", "aliases": []},
{"id": "LAYTHW", "label": "동결 해제", "aliases": []},
{"id": "LAYLCK", "label": "잠금", "aliases": []},
{"id": "LAYULK", "label": "잠금 해제", "aliases": []},
{"id": "LAYISO", "label": "분리", "aliases": []},
{"id": "LAYUNISO", "label": "분리 해제", "aliases": []},
{"id": "LAYERP", "label": "이전 상태", "aliases": []},
{"id": "LAYERSTATE", "label": "도면층 상태 저장", "aliases": ["LAS"]},
{"id": "LAYERSTATERESTORE", "label": "도면층 상태 복원", "aliases": []},
{"id": "LAYMCH", "label": "도면층 일치", "aliases": []},
{"id": "LAYCUR", "label": "현재 도면층으로", "aliases": []},
{"id": "LAYMRG", "label": "도면층 병합", "aliases": []},
{"id": "LAYDEL", "label": "도면층 삭제", "aliases": []},
{"id": "LAYWALK", "label": "도면층 탐색", "aliases": []},
{"id": "COLOR", "label": "색상", "aliases": ["COL"]},
{"id": "LINETYPE", "label": "선종류", "aliases": ["LT"]},
{"id": "LWEIGHT", "label": "선가중치", "aliases": ["LW"]},
{"id": "PROPERTIES", "label": "특성", "aliases": ["CH", "MO", "PR"]},
{"id": "QUICKPROPERTIES", "label": "빠른 특성", "aliases": ["QP"]},
{"id": "TRANSPARENCY", "label": "투명도", "aliases": []},
{"id": "GROUP", "label": "그룹", "aliases": ["G"]},
{"id": "UNGROUP", "label": "그룹 해제", "aliases": []},
{"id": "DIST", "label": "거리", "aliases": ["DI"]},
{"id": "RADIUS", "label": "반지름", "aliases": []},
{"id": "ANGLE", "label": "각도", "aliases": []},
{"id": "AREA", "label": "면적", "aliases": ["AA"]},
{"id": "MEASUREGEOM", "label": "빠른 측정", "aliases": ["MEA"]},
{"id": "ID", "label": "점 좌표", "aliases": []},
{"id": "LIST", "label": "리스트", "aliases": ["LI"]},
{"id": "QSELECT", "label": "빠른 선택", "aliases": []},
{"id": "QUICKCALC", "label": "계산기", "aliases": ["QC"]},
{"id": "SELECTSIMILAR", "label": "유사 선택", "aliases": []},
{"id": "ISOLATEOBJECTS", "label": "객체 분리", "aliases": []},
{"id": "HIDEOBJECTS", "label": "객체 숨기기", "aliases": []},
{"id": "UNISOLATEOBJECTS", "label": "객체 분리 종료", "aliases": ["UNHIDE", "UNISOLATE"]},
{"id": "COPYCLIP", "label": "복사", "aliases": []},
{"id": "CUTCLIP", "label": "잘라내기", "aliases": []},
{"id": "PASTECLIP", "label": "붙여넣기", "aliases": []},
{"id": "COPYBASE", "label": "기준점 복사", "aliases": []},
{"id": "PASTEORIG", "label": "원래 좌표로 붙여넣기", "aliases": []},
{"id": "PASTEBLOCK", "label": "그룹으로 붙여넣기", "aliases": []},
{"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]},
{"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]},
{"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]},
{"id": "SELECT", "label": "선택", "aliases": []},
{"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]},
{"id": "ZOOMIN", "label": "확대", "aliases": []},
{"id": "ZOOMOUT", "label": "축소", "aliases": []},
{"id": "REGEN", "label": "재생성", "aliases": ["RE"]},
{"id": "PAN", "label": "초점이동", "aliases": ["P"]},
{"id": "QSAVE", "label": "저장", "aliases": ["SAVE"]},
{"id": "RECOVER", "label": "백업 복구", "aliases": []},
{"id": "EXPORT", "label": "JSON 내보내기", "aliases": ["EXP"]},
{"id": "EXPORTDXF", "label": "DXF 내보내기", "aliases": []},
{"id": "EXPORTDWG", "label": "DWG 내보내기", "aliases": []},
{"id": "EXPORTSVG", "label": "SVG 내보내기", "aliases": []},
{"id": "EXPORTPNG", "label": "PNG 내보내기", "aliases": []},
{"id": "TOAREA", "label": "면적산출로 보내기", "aliases": []},
{"id": "UNDO", "label": "실행 취소", "aliases": ["U"]},
{"id": "REDO", "label": "다시 실행", "aliases": []}
]
@@ -81,7 +81,7 @@ export const TEXT_COMMANDS: CadCommand[] = [
{
id: 'TEXTEDIT',
label: '문자 편집',
aliases: ['TEDIT'],
aliases: ['ED', 'TEDIT'],
glyph: '✎',
hint: '기존 문자의 내용을 고친다',
tool: Tool.TEXTEDIT,
@@ -7,7 +7,8 @@ import { requestDrawingExport, requestHostSave } from '../integration/aislo-draw
import { runSendSelectionTable } from '../integration/aislo-table-export';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { isHostSave, redo, undo } from '../state';
import { redoCommand, undoCommand } from '../inputController/prompt-input';
import { isHostSave } from '../state';
import type { CadCommand } from './command.types';
export const FILE_COMMANDS: CadCommand[] = [
@@ -102,7 +103,7 @@ export const FILE_COMMANDS: CadCommand[] = [
aliases: ['U'],
glyph: '↶',
run: () => {
undo();
undoCommand(); // 끊고 뒤 되돌림 — Ctrl+Z 와 한 길
return '실행 취소';
},
},
@@ -111,7 +112,7 @@ export const FILE_COMMANDS: CadCommand[] = [
label: '다시 실행',
glyph: '↷',
run: () => {
redo();
redoCommand();
return '다시 실행';
},
},
@@ -17,6 +17,7 @@ import {
chamferToolStateMachine,
filletToolStateMachine,
} from '../tools/modify/corner-tools';
import { lengthenToolStateMachine } from '../tools/modify/lengthen-tool';
import {
drawOrderBackToolStateMachine,
drawOrderFrontToolStateMachine,
@@ -32,11 +33,10 @@ import {
extendToolStateMachine,
joinToolStateMachine,
} from '../tools/modify/structure-tools';
import { offsetToolStateMachine } from '../tools/modify/offset-tool';
import {
alignToolStateMachine,
lengthenToolStateMachine,
mirrorToolStateMachine,
offsetToolStateMachine,
stretchToolStateMachine,
} from '../tools/modify/transform-tools';
import { moveToolStateMachine } from '../tools/move-tool';
@@ -6,6 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version';
import { getScreenCanvasDrawController, setEntities, getEntities } from '../state';
import { Tool } from '../tools';
import { selectToolStateMachine } from '../tools/select-tool';
import { startZoomPrompt } from '../inputController/zoom-prompt';
const zoomBy = (factor: number): string => {
const controller = getScreenCanvasDrawController();
@@ -32,7 +33,6 @@ export const VIEW_COMMANDS: CadCommand[] = [
{
id: 'SELECT',
label: '선택',
aliases: ['SE'],
glyph: '↖',
hint: '객체를 클릭하거나 선택 사각형으로 고른다',
tool: Tool.SELECT,
@@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [
label: '범위 줌',
aliases: ['Z'],
glyph: '⛶',
hint: '도면 전체가 보이도록 화면을 맞춘다',
hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)',
run: () => {
getScreenCanvasDrawController().zoomToFitScreen();
return '도면 전체 보기';
startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길
return '';
},
},
{
@@ -0,0 +1,177 @@
/**
* 물음 꼴(337 설계 2-3 · 2-4 · 2-9) — 오토캐드 명령행 한 줄 「글 또는 [이름(키)/…] <기본>:」 ·
* 옵션 맞추기(initget 줄임 규칙) · 세션 기억(앞에 쓴 값이 다음 <기본>).
* 도구는 상태 `meta: { kind, prompt }` 로 물음을 선언하고 `OPTION.<key>` 사건으로 옵션을 받는다.
* 이 파일은 상태(state.ts)를 읽지 않음 — 기억은 탭 수명 Map 하나.
*/
import type { StepKind } from '../tools/tool.types';
export type XY = { x: number; y: number };
/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.<key>` · 맞추기는 kw · en */
export interface OptionSpec {
key: string;
kw: string;
en: string;
label: string;
hidden?: true;
}
export type DefaultSpec =
| { kind: 'value'; remember: string; initial: number | XY | null; format?: 'len' | 'int' | 'ang' }
/** <변위> · <전체(T)>(label 로 바꿈) — 빈 Enter = `OPTION.<key>` */
| { kind: 'option'; key: string; label?: string }
/** <첫 번째 점을 변위로 사용> · <종료> · <객체 선택> — 빈 Enter = `ENTER` */
| { kind: 'enter'; label: string };
export interface PromptSpec {
/** '다음 점 지정' — ' 또는 [..]' · ' <..>' · ':' 는 renderPrompt 가 붙임 */
text: string;
/** 넣은 옵션(숨김 포함) */
options?: OptionSpec[];
/** 오토캐드엔 있고 우리엔 없음 — 치면 안내 · 안 그림 */
absent?: OptionSpec[];
default?: DefaultSpec;
number?: { int?: true; min?: number; max?: number; positive?: true };
/** 거절 글 바꿈(없으면 물음 종류 기본 글) */
reject?: string;
/** ↑↓ 최근 값 묶음(없으면 text) */
history?: string;
}
// biome-ignore lint/suspicious/noExplicitAny: 도구마다 context 꼴이 다름
export type PromptLike = PromptSpec | ((context: any) => PromptSpec);
/** 상태 meta 꼴 — `instructions` 는 옛 꼴(전환 중) */
export interface PromptMeta {
kind?: StepKind;
prompt?: PromptLike;
instructions?: string;
}
/** 함수 꼴 물음을 지금 context 로 풂 */
export const resolvePrompt = (prompt: PromptLike | undefined, context: unknown): PromptSpec | null =>
prompt ? (typeof prompt === 'function' ? prompt(context) : prompt) : null;
// ---- 세션 기억(설계 2-9) ----
const memory = new Map<string, unknown>();
let lastPicked: string[] = [];
let lastPointValue: XY | null = null;
export const remember = (key: string, value: unknown): void => {
memory.set(key, value);
};
export const recall = <T = unknown>(key: string): T | undefined => memory.get(key) as T | undefined;
/** 고르기 이전(P) — 명령이 고른 것을 쓸 때 */
export const rememberSelection = (ids: string[]): void => {
lastPicked = [...ids];
};
export const previousSelection = (): string[] => lastPicked;
/** 마지막 점(LASTPOINT) — `@` 의 기준 */
export const rememberPoint = (point: XY): void => {
lastPointValue = { x: point.x, y: point.y };
};
export const lastPoint = (): XY | null => lastPointValue;
// ---- 그림(설계 2-3) ----
const isXY = (value: unknown): value is XY =>
typeof value === 'object' &&
value !== null &&
typeof (value as XY).x === 'number' &&
typeof (value as XY).y === 'number';
/** 기본 값 — 기억 우선 · 없으면 처음 값 */
export function defaultValue(spec: DefaultSpec & { kind: 'value' }): number | XY | null {
const remembered = recall<number | XY>(spec.remember);
return remembered ?? spec.initial;
}
const formatNumber = (value: number, format: 'len' | 'int' | 'ang' = 'len'): string => {
if (format === 'int') return String(Math.round(value));
if (format === 'ang') return String(Number(value.toFixed(4)));
return value.toFixed(4); // LUPREC 4
};
function renderDefault(prompt: PromptSpec): string | null {
const spec = prompt.default;
if (!spec) return null;
if (spec.kind === 'enter') return spec.label;
if (spec.kind === 'option') {
return spec.label ?? prompt.options?.find((option) => option.key === spec.key)?.label ?? null;
}
const value = defaultValue(spec);
if (value === null) return null;
if (isXY(value)) return `${value.x.toFixed(4)}, ${value.y.toFixed(4)}`;
return formatNumber(value, spec.format);
}
/** 「다음 점 지정 또는 [닫기(C)/명령 취소(U)] <10.0000>:」 · 키워드 물음은 「또는」 없이 「옵션 입력 [..] <I>:」 */
export function renderPrompt(prompt: PromptSpec, kind?: StepKind): string {
const shown = (prompt.options ?? []).filter((option) => !option.hidden);
const joiner = kind === 'keyword' ? ' ' : ' 또는 ';
const options = shown.length
? `${joiner}[${shown.map((option) => `${option.label}(${option.kw})`).join('/')}]`
: '';
const fallback = renderDefault(prompt);
return `${prompt.text}${options}${fallback === null ? '' : ` <${fallback}>`}:`;
}
// ---- 옵션 맞추기(설계 2-4) ----
/** 친 글 → 맞추기 꼴 — 빈칸 뗌 · 대문자 · 앞 `_` 하나 뗌 */
export const normalizeKeyword = (text: string): string => text.trim().toUpperCase().replace(/^_/, '');
const matches = (text: string, option: OptionSpec): boolean =>
text === option.kw || text === option.en || (option.en.startsWith(text) && text.startsWith(option.kw));
export type OptionMatch = { option: OptionSpec } | { absent: OptionSpec } | null;
/** 친 글이 맞는 옵션 — kw 딱 맞음 우선 · 숨김도 · 없는 옵션(absent)은 안내용 */
export function matchOption(text: string, prompt: PromptSpec | null | undefined): OptionMatch {
if (!prompt) return null;
const typed = normalizeKeyword(text);
if (!typed) return null;
const pick = (list: OptionSpec[] | undefined) => {
const hits = (list ?? []).filter((option) => matches(typed, option));
return hits.find((option) => option.kw === typed) ?? hits[0];
};
const option = pick(prompt.options);
if (option) return { option };
const absent = pick(prompt.absent);
return absent ? { absent } : null;
}
// ---- 공용 옵션 ----
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
key,
kw,
en,
label,
});
const hidden = (option: OptionSpec): OptionSpec => ({ ...option, hidden: true });
/** 여러 명령이 같이 쓰는 옵션 — 글자가 다른 명령(종료(X) 등)은 그 도구가 따로 만듦 */
export const OPT = {
UNDO: opt('UNDO', 'U', '명령 취소'),
CLOSE: opt('CLOSE', 'C', '닫기'),
EXIT: opt('EXIT', 'E', '종료'),
MULTIPLE: opt('MULTIPLE', 'M', '다중'),
YES: opt('YES', 'Y', '예'),
NO: opt('NO', 'N', '아니오'),
DISPLACEMENT: opt('DISPLACEMENT', 'D', '변위'),
COPY: opt('COPY', 'C', '복사'),
REFERENCE: opt('REFERENCE', 'R', '참조'),
TEXT: opt('TEXT', 'T', '문자'),
ALL: hidden(opt('ALL', 'ALL', '전체')),
LAST: hidden(opt('LAST', 'L', '최종')),
PREVIOUS: hidden(opt('PREVIOUS', 'P', '이전')),
} as const;
/** 고르기 물음 공용(설계 2-15) — 숨은 옵션 ALL · L · P 의 처리는 선택 도구 몫 */
export const SELECT_OBJECTS: PromptSpec = {
text: '객체 선택',
options: [OPT.ALL, OPT.LAST, OPT.PREVIOUS],
};
@@ -23,6 +23,8 @@ import {
} from './commands.utility';
import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify';
import { VIEW_COMMANDS } from './commands.view';
import { inputPrefs } from '../../../../A00_Common/input_prefs';
import '../tools/factories/end-command'; // 명령 끝 → 쉬는 상태 갈고리
const ALL_COMMANDS: CadCommand[] = [
...DRAW_COMMANDS,
@@ -112,11 +114,23 @@ export const TOOL_STATE_MACHINES = Object.fromEntries(
])
) as Record<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();
const text = input.trim().toUpperCase().replace(/^_/, '');
if (!text) return undefined;
return COMMANDS_BY_ID.get(text) ?? COMMANDS_BY_ALIAS.get(text) ?? matchCommandPrefixes(text)[0];
return (
COMMANDS_BY_ID.get(text) ??
userAliasCommands((alias) => alias === text)[0] ??
COMMANDS_BY_ALIAS.get(text)
);
}
/** 접두사가 같은 명령 후보 (커서 옆 자동완성 목록에 쓴다) */
@@ -127,7 +141,8 @@ export function matchCommandPrefixes(input: string): CadCommand[] {
(command.aliases ?? []).some((alias) => alias.startsWith(text))
);
const byId = ALL_COMMANDS.filter((command) => command.id.startsWith(text));
return [...new Set([...byAlias, ...byId])];
const byUser = userAliasCommands((alias) => alias.startsWith(text));
return [...new Set([...byUser, ...byAlias, ...byId])];
}
/** 명령행 자동완성에 보여줄 문자열 (예: "LINE (L) 선") */
@@ -2,6 +2,8 @@
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent } from '../App.types';
import { releaseOnePoint } from '../inputController/input-angle-lock';
import { cancelActiveCommand } from '../inputController/prompt-input';
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
import type { CadCommand } from './command.types';
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
@@ -14,7 +16,8 @@ const commandHistory: string[] = [];
export const getLastCommandId = (): string | null => lastCommandId;
export const getCommandHistory = (): string[] => commandHistory;
function log(line: string) {
/** 기록 줄에 한 줄(명령 · 거절 · 알림) */
export function logLine(line: string) {
commandHistory.push(line);
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
@@ -28,27 +31,29 @@ export function runCommand(command: CadCommand): string {
// 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정).
if (isDrawingReadOnly() && !isViewOnlyCommand(command)) {
toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`);
log(`${command.id}: 확정 도면 — 실행하지 않음`);
logLine(`${command.id}: 확정 도면 — 실행하지 않음`);
return '';
}
if (command.needsSelection && getSelectedEntities().length === 0) {
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
log(`${command.id}: 선택 없음`);
logLine(`${command.id}: 선택 없음`);
return '';
}
lastCommandId = command.id;
releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음
if (command.machine) {
cancelActiveCommand(); // 앞 명령을 끊고 켬(오토캐드 ^C^C) · 즉시형은 도구를 둠
setActiveToolActor(new Actor(command.machine));
// 도구를 켠 뒤 준비 동작이 필요한 명령(파일 선택 등)은 run()도 함께 부른다
command.run?.();
log(`${command.id} ${command.label}`);
logLine(`${command.id} ${command.label}`);
return command.id;
}
const result = command.run?.();
log(`${command.id} ${typeof result === 'string' ? result : command.label}`);
logLine(`${command.id} ${typeof result === 'string' ? result : command.label}`);
return typeof result === 'string' ? result : command.id;
}
@@ -3,16 +3,20 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react';
import { describeCommand, matchCommandPrefixes } from '../commands/registry';
import { getCommandHistory, runCommandInput } from '../commands/run-command';
import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink';
import { getInputController, getLastStateInstructions } from '../state';
import { commandCandidate, recallInput } from '../inputController/input-history';
import { currentPrompt } from '../inputController/prompt-input';
import { getInputController } from '../state';
export const CommandLine: FC = () => {
const [text, setText] = useState('');
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
/** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */
const [pick, setPick] = useState<{ base: string; index: number } | null>(null);
const [fromHistory, setFromHistory] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const history = getCommandHistory();
const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.';
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -25,17 +29,28 @@ export const CommandLine: FC = () => {
// 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다
getInputController().submitText(value);
setText('');
setHistoryIndex(null);
setPick(null);
setFromHistory(false);
};
const recallHistory = (direction: -1 | 1) => {
if (!history.length) return;
const nextIndex =
historyIndex === null
? history.length - 1
: Math.min(history.length - 1, Math.max(0, historyIndex + direction));
setHistoryIndex(nextIndex);
setText(history[nextIndex].split(' ')[0]);
/** 후보 보이면 후보 돌림 · 아니면 친 명령 · 최근 값 기록(캔버스와 같은 함수 · 알림 줄 안 섞음) */
const recall = (direction: -1 | 1) => {
if (pick || (!fromHistory && text.trim() && suggestions.length)) {
cycle(direction);
return;
}
const value = recallInput(direction, text);
if (value === null) return;
setText(value);
setFromHistory(true);
};
const cycle = (direction: -1 | 1) => {
const base = pick?.base ?? text;
const index = pick ? pick.index + direction : direction > 0 ? 0 : -1;
const name = commandCandidate(base, index);
if (!name) return;
setPick({ base, index });
setText(name);
};
return (
@@ -52,7 +67,11 @@ export const CommandLine: FC = () => {
id="cad-command"
ref={inputRef}
value={text}
onChange={(event) => setText(event.target.value)}
onChange={(event) => {
setText(event.target.value);
setPick(null);
setFromHistory(false);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
if (event.key === ' ' && !activeToolWantsFreeText()) {
@@ -67,17 +86,13 @@ export const CommandLine: FC = () => {
event.preventDefault();
getInputController().handleEscapeKey();
}
if (event.key === 'ArrowUp') {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(-1);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(1);
recall(event.key === 'ArrowUp' ? -1 : 1);
}
if (event.key === 'Tab' && suggestions.length) {
event.preventDefault();
setText(suggestions[0].id);
cycle(1);
}
}}
placeholder="명령 입력 (예: L, LINE, REC, MOVE)"
@@ -1,6 +1,8 @@
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { redoCommand, undoCommand } from '../inputController/prompt-input';
import { zoomExtents } from '../inputController/zoom-prompt';
import {
getScreenCanvasDrawController,
isDrawingReadOnly,
@@ -17,10 +19,10 @@ export const ViewControls: FC = () => {
<div className="cad-view-controls controls">
{showUndo && (
<>
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
↶
</button>
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
↷
</button>
</>
@@ -28,7 +30,7 @@ export const ViewControls: FC = () => {
<button
type="button"
className="cad-view-controls__fit"
onClick={() => runCommandInput('ZOOM')}
onClick={() => zoomExtents()}
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
>
⛶
@@ -51,6 +51,8 @@ export class MeasurementEntity implements Entity {
private startPoint: Point;
private endPoint: Point;
private offsetPoint: Point;
/** 치수 문자 바꿈(DIMLINEAR 문자(T)) — `<>` = 잰 값 · 없으면 잰 값 */
public textOverride?: string;
constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) {
this.layerId = layerId;
@@ -75,6 +77,17 @@ export class MeasurementEntity implements Entity {
this.offsetPoint = point;
}
/** 잰 값(치수 소수 자릿수) */
public getMeasuredText(): string {
return String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
}
/** 보이는 치수 문자 */
public getLabel(): string {
const measured = this.getMeasuredText();
return this.textOverride ? this.textOverride.replaceAll('<>', measured) : measured;
}
public getDrawPoints() {
// Return if measurement is zero length
if (isPointEqual(this.startPoint, this.endPoint)) {
@@ -301,7 +314,7 @@ export class MeasurementEntity implements Entity {
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const distance = this.getLabel();
const originalTextDirection = normalUnit.rotate90CW();
let finalTextDirection = originalTextDirection;
if (
@@ -343,12 +356,14 @@ export class MeasurementEntity implements Entity {
}
public clone(): MeasurementEntity {
return new MeasurementEntity(
const copy = new MeasurementEntity(
getActiveLayerId(),
this.startPoint.clone(),
this.endPoint.clone(),
this.offsetPoint.clone()
);
copy.textOverride = this.textOverride;
return copy;
}
public intersectsWithBox(box: Box): boolean {
@@ -416,7 +431,7 @@ export class MeasurementEntity implements Entity {
];
// Calculate text properties
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const distance = this.getLabel();
const worldFactor = annotationWorldFactor();
const textHeight = getDimTextHeight() / worldFactor;
// Estimate width: textString.length * fontSize * aspectRatioFactor
@@ -582,6 +597,7 @@ export class MeasurementEntity implements Entity {
startPoint: { x: this.startPoint.x, y: this.startPoint.y },
endPoint: { x: this.endPoint.x, y: this.endPoint.y },
offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y },
...(this.textOverride ? { textOverride: this.textOverride } : {}),
},
};
}
@@ -611,6 +627,7 @@ export class MeasurementEntity implements Entity {
measurementEntity.lineColor = jsonEntity.lineColor;
measurementEntity.lineWidth = jsonEntity.lineWidth;
measurementEntity.lineDash = jsonEntity.lineDash;
measurementEntity.textOverride = jsonEntity.shapeData.textOverride;
return measurementEntity;
}
}
@@ -619,4 +636,5 @@ export interface MeasurementJsonData {
startPoint: { x: number; y: number };
endPoint: { x: number; y: number };
offsetPoint: { x: number; y: number };
textOverride?: string;
}
@@ -5,12 +5,17 @@ import {
getLayerById,
getScreenCanvasDrawController,
getShouldDrawHelpers,
getSnapEnabled,
getSnapTrackingEnabled,
setAngleGuideEntities,
setSnapPoint,
setSnapPointOnAngleGuide,
} from '../state.ts';
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
import {
HOVERED_SNAP_POINT_TIME,
SNAP_ANGLE_DISTANCE,
SNAP_POINT_DISTANCE,
} from '../App.consts.ts';
import { getDrawHelpers } from './get-draw-guides.ts';
import { queryEntitiesNearPoint } from './spatial-index.ts';
import { compact } from 'es-toolkit';
@@ -26,19 +31,24 @@ export function calculateAngleGuidesAndSnapPoints() {
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
// 잠금 레이어(b08-frame 등 참조용)는 스냅 대상에서 제외한다.
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
const entities = queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2
).filter((entity) => !getLayerById(entity.layerId)?.isLocked);
// 객체 스냅(F3)을 끄면 객체 스냅점 · 추적만 빠짐 — 직교 · 극 가이드는 늘(H8)
const snapEnabled = getSnapEnabled();
const entities = snapEnabled
? queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2
).filter((entity) => !getLayerById(entity.layerId)?.isLocked)
: [];
const hoveredSnapPoints = getHoveredSnapPoints();
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
? hoveredSnapPoints.filter(
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
)
: [];
const eligibleHoveredSnapPoints =
snapEnabled && getSnapTrackingEnabled()
? hoveredSnapPoints.filter(
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
)
: [];
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
@@ -50,7 +60,8 @@ export function calculateAngleGuidesAndSnapPoints() {
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
maxSnapDistance
maxSnapDistance,
SNAP_ANGLE_DISTANCE / screenScale
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
@@ -13,6 +13,7 @@ import {
getShouldDrawCursor,
getSnapPoint,
getSnapPointOnAngleGuide,
isDrawingReadOnly,
isOriginMarker,
isViewOnly,
} from '../state';
@@ -53,7 +54,8 @@ export function draw(drawController: ScreenCanvasDrawController) {
if (isOriginMarker()) drawOriginMarker(drawController);
drawGuide(drawController); // 작도 영역 점선(169-1) — 겹판이라 저장 · 인쇄 안 됨
drawGrips(drawController, getSelectedEntities());
// 보기 전용 · 확정 도면은 그립 없음(H2 — 집지도 못함)
if (!isViewOnly() && !isDrawingReadOnly()) drawGrips(drawController, getSelectedEntities());
drawHelpers(drawController, getAngleGuideEntities());
drawEntities(drawController, getGhostHelperEntities());
drawDebugEntities(drawController, getDebugEntities());
@@ -16,13 +16,15 @@ import { getIntersectionPoints } from './get-intersection-points';
* @param worldMouseLocation the current mouse location
* @param angleStep the angle in degrees at which the angle guides should be drawn
* @param maxSnapDistance The distance that the mouse can snap to a snap point or angle guide
* @param angleSnapDistance 가이드를 잡는 거리(도면 단위) — 화면 픽셀 / 배율로 넘김(H9)
*/
export function getDrawHelpers(
entities: Entity[],
anglePoints: Point[],
worldMouseLocation: Point,
angleStep: number,
maxSnapDistance: number
maxSnapDistance: number,
angleSnapDistance: number = SNAP_ANGLE_DISTANCE
): {
angleGuides: LineEntity[];
entitySnapPoint: SnapPoint | null;
@@ -39,7 +41,7 @@ export function getDrawHelpers(
const closestLineInfo = findClosestEntity<LineEntity>(worldMouseLocation, angleGuideLines);
if (closestLineInfo.distance < SNAP_ANGLE_DISTANCE) {
if (closestLineInfo.distance < angleSnapDistance) {
angleGuides.push(closestLineInfo.entity);
nearestAngleSnapPoints.push({
point: closestLineInfo.segment.start,
@@ -1,4 +1,6 @@
import { type Point, Vector } from '@flatten-js/core';
import { getAngleLock, pointAtLock, projectOnLock } from '../inputController/input-angle-lock.ts';
import { isObjectSnap, orthoOn, orthoPoint, snapAt } from '../inputController/ortho.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
@@ -12,26 +14,39 @@ 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`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로 · 직교(F8 · Shift)면 가로 · 세로로.
* @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();
if (locked) return projectOnLock(startPoint, mouse);
// 직교 — 누르면 객체 스냅에 잡힐 자리는 그대로(누름과 같게)
return startPoint && orthoOn() && !isObjectSnap(snapAt(mouse))
? orthoPoint(startPoint, mouse)
: mouse;
}
if (event.type === ActorEvent.MOUSE_CLICK) {
return (event as MouseClickEvent).worldMouseLocation;
const clickEvent = event as MouseClickEvent;
const click = clickEvent.worldMouseLocation;
if (locked) return projectOnLock(startPoint, click);
return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift)
? orthoPoint(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;
// Direction indicated by the startPoint and the mouse location
const direction = new Vector(
event.worldMouseLocation.x - startPoint.x,
event.worldMouseLocation.y - startPoint.y
);
if (locked) return pointAtLock(startPoint, distance);
// Direction indicated by the startPoint and the mouse location — 직교면 가로 · 세로
const toward = orthoOn()
? orthoPoint(startPoint, event.worldMouseLocation)
: event.worldMouseLocation;
const direction = new Vector(toward.x - startPoint.x, toward.y - startPoint.y);
const unitDirection = direction.normalize();
return startPoint.translate(unitDirection.multiply(distance));
}
@@ -112,7 +112,7 @@ describe('Space = Enter(오토캐드)', () => {
expect(activeTool()).toBe(Tool.TEXT);
});
it('빈 Space · Enter = 마지막 명령 반복 · 빈 우클릭은 반복 안 함', () => {
it('빈 Space · Enter · 우클릭 = 마지막 명령 반복(337 · 쉴 때 우클릭도)', () => {
selectIdle();
const controller = new InputController();
press(controller, 'l');
@@ -135,6 +135,6 @@ describe('Space = Enter(오토캐드)', () => {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as MouseEvent);
expect(activeTool()).toBe(Tool.SELECT);
expect(activeTool()).toBe(Tool.LINE);
});
});
@@ -9,12 +9,6 @@ import { getActiveToolActor } from '../state.ts';
export const isImeKey = (evt: KeyboardEvent): boolean =>
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
export function activeToolAcceptsText(): boolean {
const snapshot = getActiveToolActor()?.getSnapshot();
return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT;
}
/** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */
export function activeToolWantsFreeText(): boolean {
const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as
@@ -23,6 +17,9 @@ export function activeToolWantsFreeText(): boolean {
return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text');
}
/** 한글 받개에 초점을 줄 단계 = 글 물음(`kind: 'text'`)만 — 숫자 · 키워드 물음은 영문 자리 글자 */
export const activeToolWantsIme = (): boolean => activeToolWantsFreeText();
/** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */
export const isPlainSpace = (
evt: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' | 'repeat'>
@@ -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);
}
@@ -1,82 +1,85 @@
import { Point } from '@flatten-js/core';
import { compact, round } from 'es-toolkit';
import { Actor } from 'xstate';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { MouseButton } from '../App.types.ts';
import {
describeCommand,
matchCommandPrefixes,
resolveCommandInput,
} from '../commands/registry.ts';
import { repeatLastCommand, runCommand } from '../commands/run-command.ts';
import { describeCommand, matchCommandPrefixes } from '../commands/registry.ts';
import { logLine, runCommandInput } from '../commands/run-command.ts';
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import { pickRadius } from '../helpers/pick-radius.ts';
import {
getActiveToolActor,
getAngleGuideOriginPoint,
getAngleStep,
getCanvas,
getHighlightedEntityIds,
getPickableEntities,
getGridEnabled,
getLastStateInstructions,
getPanStartLocation,
getScreenCanvasDrawController,
getSelectedEntities,
getSnapEnabled,
getSnapPoint,
getSnapPointOnAngleGuide,
isHostSave,
isViewOnly,
redo,
setActiveToolActor,
setAngleStep,
setGhostHelperEntities,
setGridEnabled,
setHighlightedEntityIds,
setPanStartLocation,
setSelectedEntityIds,
setShouldDrawCursor,
setSnapEnabled,
undo,
} from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
type MouseClickEvent,
type NumberInputEvent,
type RelativePointInputEvent,
type TextInputEvent,
} from '../tools/tool.types.ts';
import {
activeToolAcceptsText,
activeToolWantsFreeText,
ImeSink,
isImeKey,
isPlainSpace,
} from './ime-sink.ts';
import { drawInputField } from './input-field-draw.ts';
import { ctrlShortcut } from './input-shortcut.ts';
import { SelectState } from '../tools/select-tool.ts';
import { ActorEvent, type MouseClickEvent } from '../tools/tool.types.ts';
import { activeToolWantsFreeText, activeToolWantsIme, ImeSink, isImeKey, isPlainSpace } from './ime-sink.ts';
import { drawInputField, dynamicInputLine } from './input-field-draw.ts';
import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import { isObjectSnap, noteShift, snapAt } from './ortho.ts';
import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts';
import { openSnapMenu } from './snap-menu.ts';
import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
import { attachTouch } from './input-touch.ts';
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
import { commandCandidate, pushInput, recallInput } from './input-history.ts';
import {
ABSOLUTE_POINT_REGEXP,
ABSOLUTE_POLAR_REGEXP,
NUMBER_REGEXP,
polarToPoint,
RELATIVE_POINT_REGEXP,
RELATIVE_POLAR_REGEXP,
} from './input-patterns.ts';
angleLockHint,
getAngleLock,
getDynamicField,
getLockedLength,
releaseOnePoint,
tabDynamicInput,
} from './input-angle-lock.ts';
import {
getSnapOverride,
setSnapOverride,
snapOverrideHint,
snapOverridePoint,
} from './input-snap-override.ts';
import {
cancelActiveCommand,
closeSubPrompt,
currentNotice,
currentPrompt,
dynamicInputApplies,
isIdle,
notice,
redoCommand,
routeTyped,
sendPoint,
undoCommand,
} from './prompt-input.ts';
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
export class InputController {
private text = '';
/** ↑↓ · Tab 돌림 — 기록인가 후보인가 · 후보의 처음 친 글 · 몇째 */
private browsing: 'history' | 'candidates' | null = null;
private candidateBase = '';
private candidateIndex = 0;
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
private leftPressLocation: Point | null = null;
public readonly ime = new ImeSink((data) => {
@@ -125,16 +128,33 @@ export class InputController {
}
public draw(drawController: ScreenCanvasDrawController) {
this.ime.sync(activeToolAcceptsText());
const toolInstruction = getLastStateInstructions();
this.ime.sync(activeToolWantsIme());
const toolInstruction = currentPrompt().line;
const texts: string[] = [];
if (toolInstruction) {
// Draw tool instruction
texts.push(toolInstruction);
}
texts.push(...compact([currentNotice()])); // 거절 · 안내 — 둘째 줄
if (toolInstruction) {
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
texts.push(`${roundedX},${roundedY}`);
}
const base = getAngleGuideOriginPoint();
if (base && 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());
// 입력 글(조합 중인 한글도 함께)
@@ -149,10 +169,14 @@ export class InputController {
public handleMouseUp(evt: MouseEvent) {
if (evt.button === MouseButton.Right) {
// Right click => confirm action (ENTER)
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴
evt.preventDefault();
evt.stopPropagation();
this.handleEnterKey();
if (evt.shiftKey && currentPrompt().kind === 'point') {
openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name));
} else {
this.handleEnterKey(true);
}
}
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
@@ -169,22 +193,22 @@ export class InputController {
return;
}
if (evt.button === MouseButton.Left) {
if (this.isSelectToolActive()) {
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
const pressLocation = this.leftPressLocation;
const pressLocation = this.leftPressLocation;
if (pressLocation) {
// 고르기는 누를 때 첫 점을 이미 보냈다 — 끌었으면 놓는 자리로 상자를 닫거나 집은 그립을 놓음 ·
// 안 끌고 떼면 그대로(상자 클릭-클릭 · 뜨거운 그립은 다음 누름에 놓음 — H3)
this.leftPressLocation = null;
const releaseLocation = this.getCanvasPoint(evt);
const dragged =
!!pressLocation &&
Math.hypot(releaseLocation.x - pressLocation.x, releaseLocation.y - pressLocation.y) >
DRAG_SELECT_MIN_PX;
if (dragged && this.isWaitingForSecondSelectPoint()) {
DRAG_SELECT_MIN_PX;
if (dragged && (this.isGripEditActive() || isWaitingForSecondSelectPoint())) {
this.sendMouseClick(evt);
}
return;
}
this.sendMouseClick(evt);
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
this.ime.sync(activeToolWantsIme()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
}
}
@@ -193,32 +217,34 @@ export class InputController {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
}
/** 선택 도구가 선택 사각형의 두 번째 점을 기다리는 중인가 */
private isWaitingForSecondSelectPoint(): boolean {
return (
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT
);
/** 집은 그립을 옮기는 중인가 */
private isGripEditActive(): boolean {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.GRIP_EDIT;
}
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 */
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 — snapped = 객체 스냅에 잡힘(직교가 안 덮음) */
private sendMouseClick(evt: MouseEvent) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const closestSnapPoint = snapAt(screenCanvasDrawController.getWorldMouseLocation());
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
let worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
let snapped = isObjectSnap(closestSnapPoint);
if (getSnapOverride()) {
const overridden = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint());
if (!overridden) return; // 맞는 점 없음 — 다시 누르기를 기다림
worldMouseLocation = overridden;
snapped = true;
}
getActiveToolActor()?.send({
this.sendPointEvent({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
snapped,
} as MouseClickEvent);
}
@@ -228,6 +254,7 @@ export class InputController {
public handleMouseMove(evt: MouseEvent) {
setShouldDrawCursor(true);
noteShift(evt);
const screenCanvasDrawController = getScreenCanvasDrawController();
const newScreenMouseLocation = this.getCanvasPoint(evt);
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
@@ -245,13 +272,11 @@ export class InputController {
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
if (isViewOnly()) return;
// Calculate angle guides and snap points
if (getSnapEnabled()) {
calculateAngleGuidesAndSnapPoints();
}
// 가이드 · 스냅 — 가이드(직교 · 극)는 늘 · 객체 스냅점은 F3 일 때만(안에서 가름 · H8)
calculateAngleGuidesAndSnapPoints();
// Highlight the entity closest to the mouse when the select tool is active
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
// 스침 강조 — 쉴 때 · 명령 안 고르기 단계 모두(H5)
if (selectingActor()) {
const closestEntityInfo = findClosestEntity(
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
getPickableEntities()
@@ -261,6 +286,8 @@ export class InputController {
} else {
setHighlightedEntityIds([]);
}
} else if (getHighlightedEntityIds().length) {
setHighlightedEntityIds([]);
}
}
@@ -280,8 +307,15 @@ export class InputController {
}
public handleMouseDown(evt: MouseEvent) {
noteShift(evt);
if (evt.button === MouseButton.Middle) {
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
// 휠 두 번 = 범위 줌(오토캐드)
if (isMiddleDouble(this.getCanvasPoint(evt))) {
setPanStartLocation(null);
zoomExtents();
return;
}
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
@@ -293,9 +327,9 @@ export class InputController {
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!this.isSelectToolActive()) return;
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 고르기(쉼 · 명령 안 고르기 단계)에만 첫 점을 미리 보낸다.
// 다른 물음은 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!selectingActor()) return;
this.leftPressLocation = this.getCanvasPoint(evt);
this.sendMouseClick(evt);
}
@@ -343,8 +377,10 @@ export class InputController {
return;
}
const shortcut = ctrlShortcut(evt);
const cadCommand = cadCtrlCommand(evt);
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
if (isImeKey(evt) && !shortcut) return;
if (isImeKey(evt) && !shortcut && !cadCommand) return;
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(evt.key)) notice(null); // 알림 줄은 다음 키까지
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
@@ -382,7 +418,24 @@ export class InputController {
return;
}
if (evt.key === 'Tab') {
// Tab => move keyboard focus
// 쉼 + 친 글 = 명령 후보 돌림 · 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
if (isIdle() && this.text) {
evt.preventDefault();
this.cycleCandidate(1);
return;
}
if (!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 (cadCommand) {
// Ctrl+C · X · Shift+C · Shift+V · 1 — 오토캐드 클립보드 · 특성
evt.preventDefault();
runCommandInput(cadCommand);
return;
}
if (evt.ctrlKey && evt.code === 'KeyV') {
@@ -407,6 +460,7 @@ export class InputController {
// Remove the last character from the input field
evt.preventDefault();
this.text = this.text.slice(0, this.text.length - 1);
this.browsing = null;
} else if (evt.key === 'Delete') {
// User wants to delete the current selection
evt.preventDefault();
@@ -419,13 +473,16 @@ 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 === 'ArrowUp' || evt.key === 'ArrowDown') && !evt.shiftKey) {
// ↑↓ = 기록 · 최근 값 · 후보(오토캐드) — 화면 옮김은 Shift+↑↓ · ← →
this.recall(evt.key === 'ArrowUp' ? -1 : 1);
} else if (evt.key === 'ArrowDown') {
// Move the screen down
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
@@ -438,183 +495,101 @@ export class InputController {
} else if (evt.key === 'ArrowRight') {
// Move the screen right
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey));
} else if (evt.key?.length === 1) {
} else if (evt.key?.length === 1 && !evt.ctrlKey && !evt.altKey && !evt.metaKey) {
// +·-는 확대·축소 단축키로 쓰지 않는다 — 음수 좌표(-100,-50)의 첫 글자를
// 먹어 버렸다. 줌은 휠·뷰 막대·ZOOMIN/ZOOMOUT 명령으로 한다.
// User entered a single character => add to input field text
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다.
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts).
// Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것).
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
this.browsing = null;
}
}
/** ↑↓ — 후보 보일 때 = 후보 돌림 · 아니면 기록(쉼 = 친 명령 · 명령 중 = 그 물음 최근 값) */
private recall(dir: -1 | 1) {
const candidatesShown = isIdle() && !!this.text && !!commandCandidate(this.text, 0);
if (this.browsing === 'candidates' || (this.browsing === null && candidatesShown)) {
this.cycleCandidate(dir);
return;
}
const value = recallInput(dir, this.text);
if (value === null) return;
this.text = value;
this.browsing = 'history';
}
/** 명령 후보 돌림 — 처음 친 글로 시작하는 명령 이름을 차례로 친 글 자리에 */
private cycleCandidate(dir: -1 | 1) {
if (this.browsing !== 'candidates') {
this.candidateBase = this.text;
this.candidateIndex = dir > 0 ? -1 : 0;
}
const name = commandCandidate(this.candidateBase, this.candidateIndex + dir);
if (!name) return;
this.candidateIndex += dir;
this.text = name;
this.browsing = 'candidates';
}
/** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */
public handleEscapeKey() {
if (getSelectedEntities().length > 0) {
// Deselect entities
setSelectedEntityIds([]);
} else if (this.text === '') {
// Cancel tool action
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
this.text = '';
this.browsing = null;
this.ime.composing = '';
const boxing =
this.isSelectToolActive() &&
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT;
if (boxing) {
getActiveToolActor()?.send({ type: ActorEvent.ESC }); // 상자 취소
} else if (isIdle()) {
setSelectedEntityIds([]); // 쉴 때 = 고름 풂
} else {
// clear the input field
this.text = '';
cancelActiveCommand();
}
releaseOnePoint();
setSnapOverride(null);
closeSubPrompt();
logLine('*취소*');
}
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) */
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) — 쉴 때만 */
private getCommandSuggestions(): string[] {
if (this.text === '') {
if (this.text === '' || !isIdle()) {
return [];
}
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
const picking = this.browsing === 'candidates';
const candidates = matchCommandPrefixes(picking ? this.candidateBase : this.text);
const picked = picking ? candidates.findIndex((command) => command.id === this.text) : -1;
return candidates
.slice(0, 6)
.map((command, index) => `${index === picked ? '▸ ' : ''}${describeCommand(command)}`);
}
/** @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();
/**
* 친 글 제출(Enter · Space · 우클릭 · 명령행) — 해석은 `prompt-input.routeTyped` 한 곳.
* @param _repeatIfIdle 옛 꼴 — 빈 입력 + 쉼이면 늘 마지막 명령 반복(우클릭 포함)
*/
public handleEnterKey(_repeatIfIdle = false) {
const text = this.text;
this.text = '';
this.browsing = null;
pushInput(text); // ↑↓ 기록(지금 물음 묶음 — 해석 전에)
routeTyped(text);
}
if (this.text === '') {
console.log('ENTER: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
const wasSelectTool = this.isSelectToolActive();
const selectIdle =
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT;
if (repeatIfIdle && wasSelectTool && selectIdle && repeatLastCommand()) return;
// Send the ENTER event to the active tool
getActiveToolActor()?.send({
type: ActorEvent.ENTER,
});
// 선택 도구는 ENTER(우클릭)로 최종 상태에 들어가 **멈춘다**. 다른 도구가 선택을
// 자식으로 불러 쓸 때는 그 끝남이 필요하지만(move·copy·rotate의 onDone),
// 선택이 그 자체로 활성 도구일 때 멈추면 커서 옆 안내가 사라지고 이후 클릭도
// 안 먹는다(2026-08-30 사용자 지적). 그 경우에만 새로 세워 준다.
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);
this.text = '';
} else if (resolveCommandInput(this.text)) {
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
const command = resolveCommandInput(this.text);
if (command) {
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 = '';
}
/** 점 하나를 도구에 보냄(덧물음 먼저) — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
sendPoint(event);
}
public handleUndo(evt: KeyboardEvent) {
evt.preventDefault();
undo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
undoCommand();
}
public handleRedo(evt: KeyboardEvent) {
evt.preventDefault();
redo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
redoCommand();
}
}
@@ -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)}`;
}
@@ -0,0 +1,52 @@
/**
* ↑↓ 기록(337 설계 2-12 · H11) — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음(`prompt.history` · 없으면 글)의 최근 값 20.
* 보이는 기록 줄(run-command 의 log · 알림 섞임)과 나눔. 캔버스 · 명령행이 같이 씀. 글 물음(TEXT 등)은 안 남김.
*/
import { matchCommandPrefixes, resolveCommandInput } from '../commands/registry';
import { currentPrompt } from './prompt-input';
const COMMANDS = '\u0000명령';
const LIMIT = { command: 50, value: 20 };
const groups = new Map<string, string[]>();
let browsing: { group: string; index: number } | null = null;
/** 지금 물음의 기록 묶음 · 글 물음 · 모름 = null */
function groupNow(): string | null {
const prompt = currentPrompt();
if (prompt.kind === 'idle') return COMMANDS;
if (prompt.kind === 'text' || prompt.kind === null) return null;
return prompt.spec?.history ?? prompt.spec?.text ?? prompt.line;
}
/** 제출한 글 하나를 지금 물음 기록에 — 쉴 때는 명령이 된 글만 */
export function pushInput(text: string): void {
browsing = null;
const value = text.trim();
const group = groupNow();
if (!value || !group) return;
if (group === COMMANDS && !resolveCommandInput(value)) return;
const list = groups.get(group) ?? [];
if (list[list.length - 1] !== value) list.push(value);
list.splice(0, Math.max(0, list.length - (group === COMMANDS ? LIMIT.command : LIMIT.value)));
groups.set(group, list);
}
/** ↑(-1) · ↓(+1) — 지금 물음 기록에서 한 칸 · 끝을 지나면 빈 글 · 기록 없으면 null(친 글 그대로) */
export function recallInput(dir: -1 | 1, _text: string): string | null {
const group = groupNow();
const list = group ? groups.get(group) : undefined;
if (!group || !list?.length) return null;
const from = browsing?.group === group ? browsing.index : list.length;
const index = Math.min(list.length, Math.max(0, from + dir));
browsing = { group, index };
return index === list.length ? '' : list[index];
}
/** 명령 후보 돌림(Tab · 후보 보일 때 ↑↓) — base 로 시작하는 명령 중 index 번째(돌아감) */
export function commandCandidate(base: string, index: number): string | null {
const candidates = matchCommandPrefixes(base);
if (!candidates.length) return null;
const n = candidates.length;
return candidates[((index % n) + n) % n].id;
}
@@ -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;
}
@@ -2,9 +2,31 @@
* Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄.
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다.
*/
import { keyLetter } from '../../../../A00_Common/key_shortcut';
export {
ctrlShortcut,
isCtrlKey,
keyLetter,
type CtrlShortcut,
} from '../../../../A00_Common/key_shortcut';
/** CAD 만의 Ctrl 단축키 → 명령 이름(오토캐드) — Ctrl+V 는 브라우저 `paste` 사건 몫 */
export function cadCtrlCommand(
evt: Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'altKey' | 'shiftKey'> &
Partial<Pick<KeyboardEvent, 'key' | 'metaKey'>>
): string | null {
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
switch (keyLetter(evt)) {
case 'c':
return evt.shiftKey ? 'COPYBASE' : 'COPYCLIP';
case 'x':
return evt.shiftKey ? null : 'CUTCLIP';
case 'v':
return evt.shiftKey ? 'PASTEBLOCK' : null;
case '1':
return evt.shiftKey ? null : 'PROPERTIES';
default:
return null;
}
}
@@ -0,0 +1,120 @@
/**
* 한 번 스냅 — 점 단계에서 END · MID · CEN · QUA · INT · PER · NEA · NOD · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
* 이름 = 앞 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · 우리에 없는 스냅(TAN · EXT …)은 안내.
* 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: '중심',
QUA: '사분점',
INT: '교차점',
PER: '수직',
NEA: '근처점',
NOD: '노드',
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,
QUA: SnapPointType.CircleCardinal,
NOD: SnapPointType.Point,
};
/** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */
const FULL_NAMES: Record<SnapName, string> = {
END: 'ENDPOINT',
MID: 'MIDPOINT',
CEN: 'CENTER',
QUA: 'QUADRANT',
INT: 'INTERSECTION',
PER: 'PERPENDICULAR',
NEA: 'NEAREST',
NOD: 'NODE',
NON: 'NONE',
};
/** 오토캐드엔 있고 우리엔 없는 스냅 */
const ABSENT_NAMES: Record<string, string> = {
TAN: 'TANGENT',
EXT: 'EXTENSION',
INS: 'INSERTION',
APP: 'APPINT',
PAR: 'PARALLEL',
GCE: 'GCEN',
};
const byName = <K extends string>(table: Record<K, string>, text: string): K | null => {
const typed = text.trim().toUpperCase().replace(/^_/, '');
if (typed.length < 3) return null;
const hit = (Object.keys(table) as K[]).find(
(short) => typed.startsWith(short) && table[short].startsWith(typed)
);
return hit ?? null;
};
/** 친 글 → 스냅 이름(END · ENDP · ENDPOINT → END) · 아니면 null */
export const snapName = (text: string): SnapName | null => byName(FULL_NAMES, text);
/** 우리에 없는 오토캐드 스냅 이름(세 글자 꼴) · 아니면 null */
export const absentSnapName = (text: string): string | null => byName(ABSENT_NAMES, text);
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 => snapName(text) === text;
/** 커서 옆 안내 줄 */
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;
}
@@ -12,18 +12,19 @@ const wheel = (deltaY: number, deltaMode = 0, ctrlKey = false) =>
describe('CAD 휠 = 늘 줌(153-3)', () => {
it('100 · 120 배수가 아닌 델타(확대율 90 % · 110 % · 트랙패드 잔 델타)도 줌', () => {
// 기본 밂(337) — 앞으로(deltaY < 0) 굴리면 확대(음수 delta)
for (const deltaY of [100, 120, 111.1, 90.9, 4, -100, -111.1, -4]) {
expect(wheel(deltaY)).toEqual({ delta: -deltaY, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(deltaY)).toEqual({ delta: deltaY, exponent: WHEEL_ZOOM_EXPONENT });
}
});
it('줄 · 쪽 단위는 픽셀로 환산', () => {
expect(wheel(3, 1)).toEqual({ delta: -3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(3, 1)).toEqual({ delta: 3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT });
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,
};
}
@@ -0,0 +1,45 @@
/**
* 직교(337 마 · H8) — F8 이면 기준점에서 가로 · 세로로 늘 내림(커서 · 누름 · 거리 숫자 방향) ·
* Shift 누른 동안 뒤집음(오토캐드 임시 우선 키 · 마우스 사건 shiftKey 로 · keyup 안 씀) ·
* 객체 스냅 점 · 친 좌표 · 각도 잠금은 안 덮음. 직교인 동안 극 추적 가이드 점은 스냅으로 안 침.
*/
import { Point } from '@flatten-js/core';
import { compact } from 'es-toolkit';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { type SnapPoint, SnapPointType } from '../App.types.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import {
getAngleStep,
getScreenCanvasDrawController,
getSnapPoint,
getSnapPointOnAngleGuide,
} from '../state.ts';
let shiftHeld = false;
/** 마우스 사건마다 Shift 상태를 적어 둠(커서 미리보기 · 거리 숫자 방향이 읽음) */
export const noteShift = (evt: { shiftKey?: boolean }): void => {
shiftHeld = !!evt.shiftKey;
};
/** 직교 켬 = F8(각 간격 90) XOR Shift */
export const orthoOn = (shift: boolean = shiftHeld): boolean => (getAngleStep() === 90) !== shift;
/** 기준점에서 가로 · 세로 중 커서에 가까운 쪽으로 내림 */
export const orthoPoint = (base: Point, point: Point): Point =>
Math.abs(point.x - base.x) >= Math.abs(point.y - base.y)
? new Point(point.x, base.y)
: new Point(base.x, point.y);
/** 그 자리에서 누르면 잡힐 스냅 점(누름과 같은 셈 · 반경 = 화면 픽셀) */
export function snapAt(world: Point): SnapPoint | null {
return getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
world,
SNAP_POINT_DISTANCE / getScreenCanvasDrawController().getScreenScale()
);
}
/** 스냅 점이 직교를 막는가 — 극 추적 가이드 점은 직교 중엔 안 막음 */
export const isObjectSnap = (snap: SnapPoint | null): boolean =>
!!snap && snap.type !== SnapPointType.AngleGuide;
@@ -0,0 +1,83 @@
/**
* 점 물음 보조(337 설계 1-4 · 2-5 ④ · 가-2) — 한 번 스냅 이름 · FROM · M2P(MTP) · `@`(마지막 점) · `#` · `*`(절대 좌표).
* FROM · M2P 는 덧물음 두 개를 잇고 끝에 고른 점을 원래 물음에 넘김(도구 머신은 모름 → 투명).
*/
import { Point } from '@flatten-js/core';
import { lastPoint, normalizeKeyword } from '../commands/prompt';
import { ActorEvent } from '../tools/tool.types';
import { parsePointText } from './input-patterns';
import { absentSnapName, setSnapOverride, snapName } from './input-snap-override';
import { distanceCursor, notice, pushSubPrompt, sendPoint } from './prompt-input';
const point = (x: number, y: number) => new Point(x, y);
/** FROM — 「기준점:」 → 「<간격띄우기>:」(`@dx,dy` · 거리 = 기준점에서 커서 쪽) */
function startFrom() {
pushSubPrompt({
prompt: { text: '기준점' },
kind: 'point',
onPoint: (base) => {
pushSubPrompt({
prompt: { text: '<간격띄우기>' },
kind: 'point',
base,
onPoint: (target) => ({ point: target }),
});
return 'done';
},
});
}
/** M2P · MTP — 두 점의 가운데 */
function startMidBetween() {
pushSubPrompt({
prompt: { text: '중간점의 첫 번째 점' },
kind: 'point',
onPoint: (first) => {
pushSubPrompt({
prompt: { text: '중간점의 두 번째 점' },
kind: 'point',
base: first,
onPoint: (second) => ({ point: point((first.x + second.x) / 2, (first.y + second.y) / 2) }),
});
return 'done';
},
});
}
/** 점 물음에서 친 글이 보조면 처리하고 true · 아니면 false(옵션 · 좌표 차례로) */
export function tryPointModifier(text: string): boolean {
const typed = normalizeKeyword(text);
const snap = snapName(typed);
if (snap) {
setSnapOverride(snap);
return true;
}
const absent = absentSnapName(typed);
if (absent) {
notice(`「${absent}」 스냅은 이 CAD 에 없음.`);
return true;
}
if (typed === 'FROM' || typed === 'FRO') {
startFrom();
return true;
}
if (typed === 'M2P' || typed === 'MTP') {
startMidBetween();
return true;
}
if (typed === '@') {
const last = lastPoint();
if (!last) return false;
sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: point(last.x, last.y) });
return true;
}
if (typed.startsWith('#') || typed.startsWith('*')) {
// 절대 좌표 강제 — `#x,y` · `*d<a`
const event = parsePointText(typed.slice(1), distanceCursor);
if (event?.type !== ActorEvent.ABSOLUTE_POINT_INPUT) return false;
sendPoint(event);
return true;
}
return false;
}
@@ -0,0 +1,433 @@
/**
* 친 글 사다리 한 곳(337 설계 2-5 ~ 2-11) — 키보드 · 명령행 · 손가락 단추가 친 글을 지금 물음 꼴로 풂.
* 덧물음 → 빈 Enter → 글 물음 → 투명 `'` → 쉼(명령 이름) → 점 · 옵션 · 숫자 → 거절.
* 점 · 옵션 묻는 중 명령 이름은 거절(오토캐드) · 앞글자 실행 없음 · 대소문자 무관(맞추기는 `prompt.ts` 한 곳).
* 명령 끊기 · 되돌림도 여기 — 끊고(ESC) 뒤 실행(오토캐드 리본 `^C^C`).
*/
import { Point } from '@flatten-js/core';
import { Actor } from 'xstate';
import {
type PromptMeta,
type PromptSpec,
defaultValue,
matchOption,
normalizeKeyword,
renderPrompt,
resolvePrompt,
lastPoint,
} from '../commands/prompt';
import { resolveCommandInput } from '../commands/registry';
import { logLine, repeatLastCommand, runCommand } from '../commands/run-command';
import {
getActiveToolActor,
getAngleGuideOriginPoint,
getScreenCanvasDrawController,
getSnapPoint,
getSnapPointOnAngleGuide,
notifyWindow,
redo,
setActiveToolActor,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
undo,
} from '../state';
import { HtmlEvent } from '../App.types';
import { Tool } from '../tools';
import { SelectState, selectToolStateMachine } from '../tools/select-tool';
import {
ActorEvent,
type MouseClickEvent,
type StepKind,
type TextInputEvent,
} from '../tools/tool.types';
import { getDynamicField, getLockedLength, releaseOnePoint, setAngleLock } from './input-angle-lock';
import {
ANGLE_LOCK_REGEXP,
NUMBER_REGEXP,
parsePointText,
type PointTextEvent,
} from './input-patterns';
import { setSnapOverride } from './input-snap-override';
import { tryPointModifier } from './point-modifiers';
// ---- 덧물음(설계 2-11) ----
export interface SubPrompt {
prompt: PromptSpec;
kind: StepKind;
/** 상대 좌표 · 거리의 기준(FROM 간격띄우기 · M2P 둘째 점) — 없으면 지금 기준점 */
base?: Point;
onPoint?(p: Point): SubResult;
onNumber?(n: number): SubResult;
onOption?(key: string): SubResult;
onEnter?(): SubResult;
/** 꼴이 정해지지 않은 글(ZOOM 「2X」) — undefined = 안 먹음 · 사다리 그대로 */
onText?(text: string): SubResult | undefined;
}
/** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */
export type SubResult = 'stay' | 'done' | { point: Point };
const subs: SubPrompt[] = [];
export const pushSubPrompt = (sub: SubPrompt): void => {
subs.push(sub);
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const subPrompt = (): SubPrompt | null => subs[subs.length - 1] ?? null;
/** 덧물음 모두 닫음(명령 끝 · Esc) */
export const closeSubPrompt = (): void => {
subs.length = 0;
};
/** 덧물음 sub 의 답 — 'done' · {point} 면 그 덧물음만 닫음(답 안에서 다음 덧물음을 열어도 됨) */
function settle(sub: SubPrompt, result: SubResult | undefined) {
if (!result || result === 'stay') return;
const at = subs.lastIndexOf(sub);
if (at >= 0) subs.splice(at, 1);
if (result !== 'done') sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: result.point });
}
// ---- 알림 줄(설계 2-10) ----
const NOTICE_MS = 3000;
let noticeText: string | null = null;
let noticeAt = 0;
/** 커서 곁 둘째 줄(3 초 또는 다음 키까지) + 기록 줄 · null = 지움 */
export function notice(text: string | null): void {
noticeText = text;
noticeAt = Date.now();
if (text) logLine(text);
}
export const currentNotice = (): string | null =>
noticeText && Date.now() - noticeAt < NOTICE_MS ? noticeText : null;
// ---- 지금 물음 ----
const activeSnapshot = () => getActiveToolActor()?.getSnapshot();
/** 평평한 상태 설정 — 겹 상태는 읽지 않음 */
const flatConfig = () => {
const snapshot = activeSnapshot();
return typeof snapshot?.value === 'string'
? snapshot?.machine?.states?.[snapshot.value]?.config
: undefined;
};
const activeMeta = (): PromptMeta | undefined => {
const metas = Object.values((activeSnapshot()?.getMeta?.() ?? {}) as Record<string, PromptMeta>);
return metas.find((meta) => meta && (meta.kind || meta.prompt || meta.instructions));
};
/** 쉼 = 맨 위 도구가 선택 도구이고 첫 점 대기(상자 그리는 중은 아님) */
export function isIdle(): boolean {
const snapshot = activeSnapshot();
if (!snapshot || snapshot.status !== 'active') return !snapshot;
return (
snapshot.context?.type === Tool.SELECT &&
snapshot.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT
);
}
/** 물음 종류 — meta.kind · 없으면 `on` 으로 짐작 */
function toolKind(): StepKind | null {
const meta = activeMeta();
if (meta?.kind) return meta.kind;
const on = (flatConfig()?.on ?? {}) as Record<string, unknown>;
if (on.ABSOLUTE_POINT_INPUT) return 'point';
if (on.NUMBER_INPUT) return 'number';
if (on.MOUSE_CLICK) return 'entity';
if (Object.keys(on).some((key) => key.startsWith('OPTION.'))) return 'keyword';
return null;
}
export interface CurrentPrompt {
kind: StepKind | 'idle' | null;
spec: PromptSpec | null;
/** 그린 한 줄(새 꼴) 또는 옛 instructions */
line: string | null;
}
export function currentPrompt(): CurrentPrompt {
const sub = subPrompt();
if (sub) {
return { kind: sub.kind, spec: sub.prompt, line: renderPrompt(sub.prompt, sub.kind) };
}
const meta = activeMeta();
const spec = resolvePrompt(meta?.prompt, activeSnapshot()?.context);
const kind = isIdle() ? 'idle' : toolKind();
return {
kind,
spec,
line: spec ? renderPrompt(spec, meta?.kind) : (meta?.instructions ?? null),
};
}
// ---- 보내기 ----
const send = (event: { type: string; [key: string]: unknown }) =>
getActiveToolActor()?.send(event as unknown as TextInputEvent);
/** 거리의 방향 = 스냅 · 가이드 · 커서 */
export const distanceCursor = (): Point =>
getSnapPointOnAngleGuide()?.point ||
getSnapPoint()?.point ||
getScreenCanvasDrawController().getWorldMouseLocation();
export const distanceEvent = (value: number): PointTextEvent => ({
type: ActorEvent.NUMBER_INPUT,
value,
worldMouseLocation: distanceCursor(),
});
/** 동적 입력(길이 · 각도 칸 · Tab) — 기준점이 있는 점 물음에서만 */
export const dynamicInputApplies = (): boolean =>
currentPrompt().kind === 'point' && !!getAngleGuideOriginPoint();
/** base 에서 to 쪽으로 distance 간 점 */
const toward = (base: Point, to: Point, distance: number): Point => {
const length = Math.hypot(to.x - base.x, to.y - base.y) || 1;
return new Point(
base.x + ((to.x - base.x) / length) * distance,
base.y + ((to.y - base.y) / length) * distance
);
};
/** 점 하나 — 덧물음 먼저(마우스 누름도) · 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
export function sendPoint(event: PointTextEvent | MouseClickEvent): void {
const sub = subPrompt();
if (sub) {
const last = lastPoint();
const base = sub.base ?? getAngleGuideOriginPoint() ?? (last && new Point(last.x, last.y));
if (event.type === ActorEvent.NUMBER_INPUT) {
if (sub.onNumber) settle(sub, sub.onNumber(event.value));
else if (base) settle(sub, sub.onPoint?.(toward(base, event.worldMouseLocation, event.value)));
else reject(currentPrompt());
} else if (event.type === ActorEvent.MOUSE_CLICK) {
settle(sub, sub.onPoint?.(event.worldMouseLocation));
} else if (event.type === ActorEvent.RELATIVE_POINT_INPUT) {
if (base) settle(sub, sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y)));
else reject(currentPrompt());
} else {
settle(sub, sub.onPoint?.(event.value));
}
} else {
getActiveToolActor()?.send(event);
}
releaseOnePoint();
setSnapOverride(null);
}
function sendOption(key: string) {
const sub = subPrompt();
if (sub) settle(sub, sub.onOption?.(key));
else send({ type: `OPTION.${key}` });
}
function sendEnter() {
const sub = subPrompt();
if (sub) settle(sub, sub.onEnter?.());
else send({ type: ActorEvent.ENTER });
}
// ---- 거절(설계 2-10) ----
const REJECT: Record<string, string> = {
point: '점 또는 옵션 키워드를 요구함.',
pointNumber: '숫자 거리, 두 번째 점 또는 옵션 키워드를 요구함.',
number: '숫자 값을 요구함.',
keyword: '옵션 키워드가 잘못됨.',
selection: '*선택이 잘못됨*',
};
function reject(prompt: CurrentPrompt, text?: string) {
const kind = prompt.kind;
const fallback =
kind === 'point'
? prompt.spec?.number
? REJECT.pointNumber
: REJECT.point
: kind === 'number'
? REJECT.number
: kind === 'selection' || kind === 'entity'
? REJECT.selection
: REJECT.keyword;
notice(text ?? prompt.spec?.reject ?? fallback);
}
/** 숫자 검사 — 맞으면 null · 틀리면 거절 글 */
function numberProblem(value: number, rule: PromptSpec['number']): string | null {
if (!rule) return null;
if (rule.int && !Number.isInteger(value)) return '정수 값을 요구함.';
const low = rule.min ?? Number.NEGATIVE_INFINITY;
const high = rule.max ?? Number.POSITIVE_INFINITY;
if (value < low || value > high) {
return rule.min !== undefined && rule.max !== undefined
? `${rule.min}에서 ${rule.max} 사이의 ${rule.int ? '정수' : '값'}를 요구함.`
: '값이 범위를 벗어남.';
}
if (rule.positive && value <= 0) return '값은 양수이고 0이 아니어야 함.';
return null;
}
// ---- 사다리(설계 2-5) ----
/** ② 빈 Enter · Space · 우클릭 */
function routeEmpty(prompt: CurrentPrompt) {
const lockedLength = getLockedLength();
if (lockedLength !== null && dynamicInputApplies()) {
sendPoint(distanceEvent(lockedLength)); // 길이 잠근 채 Enter — 그 길이
return;
}
const fallback = prompt.spec?.default;
if (fallback?.kind === 'value') {
const value = defaultValue(fallback);
if (typeof value === 'number') {
const sub = subPrompt();
if (sub?.onNumber) settle(sub, sub.onNumber(value));
else sendPoint(distanceEvent(value));
return;
}
if (value) {
sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: new Point(value.x, value.y) });
return;
}
} else if (fallback?.kind === 'option') {
sendOption(fallback.key);
return;
}
if (prompt.kind === 'idle' && repeatLastCommand()) return;
sendEnter();
}
/** ⑧ `'이름` — 즉시형은 지금 도구 둔 채 · 도구형은 거절 */
function routeTransparent(name: string) {
const command = resolveCommandInput(name);
if (!command) {
notice(`알 수 없는 명령 「${normalizeKeyword(name)}」.`);
} else if (command.machine) {
notice(`「${command.id}」 명령은 투명하게 쓸 수 없음.`);
} else {
runCommand(command);
}
}
/** ④ 점 물음 보조 — 동적 각도 칸 · `<각도` · 한 번 스냅. 먹었으면 true */
function routePointAid(text: string): boolean {
if (getDynamicField() === 'angle' && NUMBER_REGEXP.test(text) && dynamicInputApplies()) {
setAngleLock(Number.parseFloat(text));
const lockedLength = getLockedLength();
if (lockedLength !== null) sendPoint(distanceEvent(lockedLength));
return true;
}
const lock = ANGLE_LOCK_REGEXP.exec(text);
if (lock) {
setAngleLock(Number.parseFloat(lock[1]));
return true;
}
return tryPointModifier(text); // 스냅 이름 · FROM · M2P · @ · # · *
}
/** ④ 좌표 · 거리 */
function routePointText(text: string, prompt: CurrentPrompt) {
const event = parsePointText(text, distanceCursor);
if (!event) return reject(prompt);
const hasBase = !!getAngleGuideOriginPoint() || !!subPrompt();
if (event.type === ActorEvent.RELATIVE_POINT_INPUT && !hasBase) {
// 기준점 없는 첫 점 `@dx,dy` = 마지막 점(LASTPOINT) 기준
const last = lastPoint();
if (!last) return reject(prompt);
const value = new Point(last.x + event.value.x, last.y + event.value.y);
return sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value });
}
if (event.type !== ActorEvent.ABSOLUTE_POINT_INPUT && !hasBase) return reject(prompt);
if (event.type === ActorEvent.NUMBER_INPUT) {
const problem = numberProblem(event.value, prompt.spec?.number);
if (problem) return reject(prompt, problem);
}
sendPoint(event);
}
/** ⑥ 숫자 물음 — 글 전체가 수일 때만(`3.2*1.5` · `2m` 거절) */
function routeNumber(text: string, prompt: CurrentPrompt) {
if (!NUMBER_REGEXP.test(text)) return reject(prompt);
const value = Number.parseFloat(text);
const problem = numberProblem(value, prompt.spec?.number);
if (problem) return reject(prompt, problem);
const sub = subPrompt();
if (sub) settle(sub, sub.onNumber?.(value));
else send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: distanceCursor() });
}
/** 친 글 하나를 지금 물음으로 — 늘 다 먹음(거절이면 알림 줄) */
export function routeTyped(raw: string): void {
const prompt = currentPrompt();
const text = raw.trim();
if (!text) return routeEmpty(prompt);
if (prompt.kind === 'text') {
send({ type: ActorEvent.TEXT_INPUT, value: raw }); // ③ 원문 그대로
return;
}
if (text.startsWith("'")) return routeTransparent(text.slice(1));
const textSub = subPrompt();
const own = textSub?.onText?.(text);
if (textSub && own) return settle(textSub, own);
if (prompt.kind === 'idle') {
// ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만
const command = resolveCommandInput(text);
if (command) runCommand(command);
else notice(`알 수 없는 명령 「${normalizeKeyword(text)}」.`);
return;
}
const hit = matchOption(text, prompt.spec);
// 옵션 kw · 영문 전체와 딱 같으면 옵션 먼저(ARC 「CENTER」) · 줄임은 스냅 먼저(「CEN」 = 스냅)
const exact =
hit && 'option' in hit && [hit.option.kw, hit.option.en].includes(normalizeKeyword(text));
if (prompt.kind === 'point' && !exact && routePointAid(text)) return;
// ⑤ 옵션(숨김 포함) · 없는 옵션 안내
if (hit && 'option' in hit) return sendOption(hit.option.key);
if (hit && 'absent' in hit) {
notice(`「${hit.absent.label}(${hit.absent.kw})」 옵션은 이 CAD 에 없음.`);
return;
}
if (prompt.kind === 'point') return routePointText(text, prompt);
if (prompt.kind === 'number') return routeNumber(text, prompt);
reject(prompt); // ⑦ ⑩
}
// ---- 명령 끊기 · 되돌림(설계 2-8) ----
/** 쉬는 상태로 — 옛 도구가 ESC 뒤 처음 물음으로 돌아가도 한 번에 끝 */
function startIdle() {
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
setShouldDrawHelpers(false);
setActiveToolActor(new Actor(selectToolStateMachine));
}
/** 진행 중 명령 끊기 — 쉼이면 아무 일 없음 */
export function cancelActiveCommand(): void {
closeSubPrompt();
releaseOnePoint();
setSnapOverride(null);
if (isIdle()) return;
const actor = getActiveToolActor();
actor?.send({ type: ActorEvent.ESC });
if (getActiveToolActor() === actor && !isIdle()) startIdle();
}
/** 되돌림 — 끊고 뒤 · 고름 풂(Ctrl+Z · ↶ · U) */
export function undoCommand(): void {
cancelActiveCommand();
undo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
}
export function redoCommand(): void {
cancelActiveCommand();
redo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
}
@@ -0,0 +1,31 @@
/**
* 고르기 중인 선택 도구 찾기(337 마) — 쉴 때 맨 위 선택 도구 · 명령 안 고르기 단계 = 그 명령이 부른 자식 선택 도구.
* 누를 때 첫 점(끌기 상자) · 스침 강조 · 고르기 키워드가 이 한 곳으로 같은 선택 도구를 찾는다.
*/
import type { AnyActorRef } from 'xstate';
import { getActiveToolActor } from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState } from '../tools/select-tool.ts';
import { setKeywordNotice } from '../tools/select-tool.helpers.ts';
import { notice } from './prompt-input.ts';
// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄
setKeywordNotice(notice);
const isSelect = (actor: AnyActorRef | undefined): boolean =>
actor?.getSnapshot()?.context?.type === Tool.SELECT;
/** 지금 고르기를 받는 선택 도구 — 없으면 null(점 · 숫자 물음) */
export function selectingActor(): AnyActorRef | null {
const top = getActiveToolActor() as AnyActorRef | null;
if (!top) return null;
if (isSelect(top)) return top;
const children = Object.values(top.getSnapshot()?.children ?? {}) as AnyActorRef[];
return (
children.find((child) => isSelect(child) && child.getSnapshot()?.status === 'active') ?? null
);
}
/** 선택 상자의 맞은 모서리를 기다리는 중 */
export const isWaitingForSecondSelectPoint = (): boolean =>
selectingActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT;
@@ -0,0 +1,29 @@
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
.cad-snap-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
}
.cad-snap-menu > button {
padding: 4px 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.cad-snap-menu > button:hover,
.cad-snap-menu > button:focus-visible {
background: var(--color-surface-hover, rgba(127, 127, 127, 0.15));
}
@@ -0,0 +1,76 @@
/**
* Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`).
* 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만.
*/
import './snap-menu.css';
/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */
export const SNAP_MENU: readonly [string, string][] = [
['FROM', '시작점에서'],
['M2P', '두 점 사이의 중간'],
['END', '끝점'],
['MID', '중간점'],
['INT', '교차점'],
['CEN', '중심'],
['QUA', '사분점'],
['PER', '수직'],
['NOD', '노드'],
['NEA', '근처점'],
['NON', '없음'],
];
let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null;
const onKey = (evt: KeyboardEvent) => {
if (evt.key !== 'Escape') return;
evt.preventDefault();
evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로)
closeSnapMenu();
};
const onOutside = (evt: MouseEvent) => {
if (!current?.element?.contains(evt.target as Node)) closeSnapMenu();
};
export const isSnapMenuOpen = (): boolean => current !== null;
export function closeSnapMenu(): void {
if (!current) return;
current.element?.remove();
current = null;
if (typeof window === 'undefined') return;
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('mousedown', onOutside, true);
}
/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */
export function pickSnapMenu(name: string): void {
const picked = current;
closeSnapMenu();
picked?.onPick(name);
}
/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */
export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void {
closeSnapMenu();
current = { onPick, element: null };
if (typeof document === 'undefined') return;
const menu = document.createElement('div');
menu.className = 'cad-snap-menu controls';
menu.setAttribute('role', 'menu');
for (const [name, label] of SNAP_MENU) {
const item = document.createElement('button');
item.type = 'button';
item.setAttribute('role', 'menuitem');
item.textContent = `${label} (${name})`;
item.addEventListener('click', () => pickSnapMenu(name));
menu.append(item);
}
document.body.append(menu);
// 화면 밖으로 안 나가게
const box = menu.getBoundingClientRect();
menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`;
current.element = menu;
window.addEventListener('keydown', onKey, true);
window.addEventListener('mousedown', onOutside, true);
}
@@ -0,0 +1,158 @@
/**
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음.
* 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개.
* ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명).
*/
import { Point } from '@flatten-js/core';
import type { OptionSpec, PromptSpec } from '../commands/prompt.ts';
import { getScreenCanvasDrawController } from '../state.ts';
import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts';
type View = { scale: number; offset: Point };
const MAX_VIEWS = 10;
/** 휠 두 번 — 이 안에 같은 자리(화면 픽셀)를 다시 누르면 */
const DOUBLE_MS = 400;
const DOUBLE_PX = 5;
const views: View[] = [];
let lastMiddle: { time: number; x: number; y: number } | null = null;
/** 지금 화면을 앞 화면으로 적고 바꿈 */
export function changeView(change: () => void): void {
const controller = getScreenCanvasDrawController();
views.push({ scale: controller.getScreenScale(), offset: controller.getScreenOffset() });
if (views.length > MAX_VIEWS) views.shift();
change();
}
/** 범위 줌 — 도면 전체를 화면에 */
export const zoomExtents = (): void =>
changeView(() => getScreenCanvasDrawController().zoomToFitScreen());
/** 앞 화면으로 — 없으면 false */
export function zoomPrevious(): boolean {
const view = views.pop();
if (!view) return false;
const controller = getScreenCanvasDrawController();
controller.setScreenScale(view.scale);
controller.setScreenOffset(view.offset);
return true;
}
/** 가운데 단추 누름 — 두 번째 누름이면 true(그 뒤 다시 처음부터) */
export function isMiddleDouble(point: { x: number; y: number }, time = Date.now()): boolean {
const double =
!!lastMiddle &&
time - lastMiddle.time <= DOUBLE_MS &&
Math.hypot(point.x - lastMiddle.x, point.y - lastMiddle.y) <= DOUBLE_PX;
lastMiddle = double ? null : { time, x: point.x, y: point.y };
return double;
}
/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */
function setScaleAboutCenter(scale: number): void {
const controller = getScreenCanvasDrawController();
const size = controller.getCanvasSize();
const offset = controller.getScreenOffset();
const old = controller.getScreenScale();
const cx = offset.x + size.x / 2 / old;
const cy = offset.y + size.y / 2 / old;
controller.setScreenScale(scale);
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
}
/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */
export function zoomWindow(a: Point, b: Point): void {
const controller = getScreenCanvasDrawController();
const size = controller.getCanvasSize();
const width = Math.abs(a.x - b.x);
const height = Math.abs(a.y - b.y);
const scale = Math.min(
width > 0 ? size.x / width : Number.POSITIVE_INFINITY,
height > 0 ? size.y / height : Number.POSITIVE_INFINITY
);
if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2;
changeView(() => {
controller.setScreenScale(scale);
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
});
}
const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({
key,
kw,
en: key,
label,
});
/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */
const ZOOM_PROMPT: PromptSpec = {
text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력',
options: [
zoomOption('ALL', 'A', '전체'),
zoomOption('EXTENTS', 'E', '범위'),
zoomOption('PREVIOUS', 'P', '이전'),
zoomOption('WINDOW', 'W', '윈도우'),
],
absent: [
zoomOption('CENTER', 'C', '중심'),
zoomOption('DYNAMIC', 'D', '동적'),
zoomOption('SCALE', 'S', '축척'),
zoomOption('OBJECT', 'O', '객체'),
],
default: { kind: 'enter', label: '실시간' },
};
const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' };
const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' };
const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i;
/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */
export function startZoomPrompt(): void {
let stage: 'main' | 'first' | 'other' = 'main';
let corner: Point | null = null;
const sub: SubPrompt = {
kind: 'point',
get prompt() {
return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER;
},
onPoint(point): SubResult {
if (stage === 'other' && corner) {
zoomWindow(corner, point);
return 'done';
}
corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드)
stage = 'other';
return 'stay';
},
onOption(key): SubResult {
if (key === 'WINDOW') {
stage = 'first';
return 'stay';
}
if (key === 'PREVIOUS') {
if (!zoomPrevious()) notice('앞 화면이 없음.');
return 'done';
}
zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음)
return 'done';
},
onEnter(): SubResult {
if (stage !== 'main') return 'stay';
notice('실시간 줌은 휠로.');
return 'done';
},
onText(text) {
const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null;
if (!match) return undefined;
const factor = Number.parseFloat(match[1]);
if (factor <= 0) return undefined;
const scale = getScreenCanvasDrawController().getScreenScale() * factor;
changeView(() => setScaleAboutCenter(scale));
return 'done';
},
};
pushSubPrompt(sub);
}
+2 -3
View File
@@ -174,9 +174,8 @@ function initApplication() {
startDrawLoop(screenCanvasDrawController, 0);
const lineToolActor = new Actor(TOOL_STATE_MACHINES[Tool.LINE]);
lineToolActor.start();
setActiveToolActor(lineToolActor);
// 오토캐드처럼 「명령:」 쉼(선택 도구)으로 시작
setActiveToolActor(new Actor(TOOL_STATE_MACHINES[Tool.SELECT]));
}
}
+15 -3
View File
@@ -8,13 +8,13 @@ import {
HtmlEvent,
type Layer,
type SnapPoint,
type StateMetaData,
} from './App.types';
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import type { Entity } from './entities/Entity';
import { bumpSceneVersion } from './helpers/scene-version';
import { StateVariable, type UndoState, createStack } from './helpers/undo-stack';
import type { InputController } from './inputController/input-controller.ts'; // state variables
import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt';
// state variables
/**
@@ -285,6 +285,11 @@ export const isOriginMarker = (): boolean => originMarker;
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
canvas = newCanvas;
};
let commandEndHook: (() => void) | null = null;
/** 도구 머신이 final 에 들 때 부를 것(한 곳) */
export const setCommandEndHook = (hook: () => void) => {
commandEndHook = hook;
};
export const setActiveToolActor = (
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
newToolActor: Actor<any>,
@@ -297,8 +302,14 @@ export const setActiveToolActor = (
activeToolActor.subscribe({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
next: (state: MachineSnapshot<any, any, any, any, any, any, any, any>) => {
const stateInstructions = Object.values(state?.getMeta() as Record<string, StateMetaData>)[0]
?.instructions;
// 명령 끝(final) → 쉬는 상태로(`tools/factories/end-command.ts` 가 갈고리를 붙임)
if (state?.status === 'done') {
if (activeToolActor === newToolActor) commandEndHook?.();
return;
}
const meta = Object.values(state?.getMeta() as Record<string, PromptMeta>)[0];
const prompt = resolvePrompt(meta?.prompt, state.context);
const stateInstructions = prompt ? renderPrompt(prompt, meta?.kind) : meta?.instructions;
if (getLastStateInstructions() === stateInstructions) {
return;
@@ -418,6 +429,7 @@ export const setSnapPointOnAngleGuide = (newSnapPointOnAngleGuide: SnapPoint | n
};
export const setAngleGuideOriginPoint = (newAngleGuideOriginPoint: Point | null) => {
angleGuideOriginPoint = newAngleGuideOriginPoint;
if (newAngleGuideOriginPoint) rememberPoint(newAngleGuideOriginPoint); // LASTPOINT
};
export const setHoveredSnapPoints = (newHoveredSnapPoints: HoverPoint[]) => {
hoveredSnapPoints = newHoveredSnapPoints;
@@ -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',
@@ -1,6 +1,12 @@
/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */
/**
* 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절).
* 오토캐드 물음 차례(337 설계 1-2) — DIMRADIUS · DIMDIAMETER = 「호 또는 원 선택:」 →
* 「치수선의 위치 지정 또는 [문자(T)]:」 · 한 번 뒤 끝 · 그 밖 치수 = 물음 글만.
*/
import { Arc, Circle, Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { OPT, type OptionSpec } from '../../commands/prompt';
import { logLine } from '../../commands/run-command';
import { getDimDecimals } from '../../commands/dim-settings';
import type { Entity } from '../../entities/Entity';
import { MeasurementEntity } from '../../entities/MeasurementEntity';
@@ -8,7 +14,7 @@ import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sampl
import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
import { annotationText, arrowHead, dimensionText } from './annotation.helpers';
interface CircularShape {
@@ -24,7 +30,24 @@ function circularOf(entity: Entity): CircularShape | null {
return null;
}
function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null {
/** 잰 값 글(「R10.00」 · 「Ø20.00」) */
function radialLabel(entity: Entity, diameter: boolean): string | null {
const circular = circularOf(entity);
if (!circular) return null;
const value = diameter ? circular.radius * 2 : circular.radius;
return `${diameter ? 'Ø' : 'R'}${value.toFixed(getDimDecimals())}`;
}
/** 바꾼 글(`<>` = 잰 값) · 없으면 잰 값 */
const labelWith = (measured: string, text?: string) =>
text && text !== '<>' ? text.replaceAll('<>', measured) : measured;
function radialDimension(
entity: Entity,
textPoint: Point,
diameter: boolean,
text?: string
): Entity[] | null {
const circular = circularOf(entity);
if (!circular) return null;
@@ -43,52 +66,71 @@ function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): E
return [
lineEntity(start, textPoint),
arrowHead(edge, circular.center),
dimensionText(
diameter ? circular.radius * 2 : circular.radius,
textPoint,
diameter ? 'Ø' : 'R'
),
text && text !== '<>'
? annotationText(labelWith(radialLabel(entity, diameter) as string, text), textPoint)
: dimensionText(
diameter ? circular.radius * 2 : circular.radius,
textPoint,
diameter ? 'Ø' : 'R'
),
];
}
export const dimRadiusToolStateMachine = createSequenceTool({
tool: Tool.DIMRADIUS,
steps: [
{ kind: 'entity', instructions: '반지름을 기입할 원 또는 호를 선택하십시오.' },
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
],
commit: (input) => {
const parts = radialDimension(input.entity(0), input.point(1), false);
if (!parts) {
toast.warn('원 또는 호를 선택하십시오.');
return;
}
addEntities(parts, true);
},
});
const MTEXT: OptionSpec = { key: 'MTEXT', kw: 'M', en: 'MTEXT', label: '여러 줄 문자' };
const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' };
export const dimDiameterToolStateMachine = createSequenceTool({
tool: Tool.DIMDIAMETER,
steps: [
{ kind: 'entity', instructions: '지름을 기입할 원 또는 호를 선택하십시오.' },
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
],
commit: (input) => {
const parts = radialDimension(input.entity(0), input.point(1), true);
if (!parts) {
toast.warn('원 또는 호를 선택하십시오.');
return;
}
addEntities(parts, true);
},
});
/** DIMRADIUS · DIMDIAMETER — 원 · 호 → [문자(T)] → 위치 */
function radialTool(tool: Tool, diameter: boolean) {
const measured = (input: SequenceInput) =>
input.has('obj') ? radialLabel(input.get<Entity>('obj'), diameter) : null;
return createSequenceTool({
tool,
steps: [
{ kind: 'entity', id: 'obj', prompt: { text: '호 또는 원 선택' } },
{
kind: 'text',
id: 'txt',
when: (input) => input.mode === 'text',
next: 'place',
defaultValue: '<>',
prompt: (input) => {
const value = measured(input);
return {
text: '치수 문자 입력',
...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }),
};
},
},
{
kind: 'point',
id: 'place',
prompt: { text: '치수선의 위치 지정', options: [OPT.TEXT], absent: [MTEXT, ANGLE] },
options: { TEXT: { mode: 'text', next: 'txt' } },
},
],
commit: (input) => {
const entity = input.get<Entity>('obj');
const text = input.has('txt') ? input.get<string>('txt') : undefined;
const parts = radialDimension(entity, input.get<Point>('place'), diameter, text);
if (!parts) {
toast.warn('원 또는 호를 선택하십시오.');
return;
}
addEntities(parts, true);
logLine(`치수 문자 = ${labelWith(radialLabel(entity, diameter) as string, text)}`);
},
});
}
export const dimRadiusToolStateMachine = radialTool(Tool.DIMRADIUS, false);
export const dimDiameterToolStateMachine = radialTool(Tool.DIMDIAMETER, true);
export const dimAngularToolStateMachine = createSequenceTool({
tool: Tool.DIMANGULAR,
steps: [
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
{ kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' },
{ kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' },
{ kind: 'point', prompt: { text: '각도 정점 지정' } },
{ kind: 'point', prompt: { text: '첫 번째 각도 끝점 지정' } },
{ kind: 'point', prompt: { text: '두 번째 각도 끝점 지정' } },
],
commit: (input) => {
const [vertex, first, second] = input.points();
@@ -128,8 +170,8 @@ export const dimAngularToolStateMachine = createSequenceTool({
export const dimArcToolStateMachine = createSequenceTool({
tool: Tool.DIMARC,
steps: [
{ kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' },
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
{ kind: 'entity', prompt: { text: '호 또는 폴리선 호 세그먼트 선택' } },
{ kind: 'point', prompt: { text: '호 길이 치수 위치 지정' } },
],
commit: (input) => {
const entity = input.entity(0);
@@ -146,8 +188,8 @@ export const dimArcToolStateMachine = createSequenceTool({
export const dimOrdinateToolStateMachine = createSequenceTool({
tool: Tool.DIMORDINATE,
steps: [
{ kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' },
{ kind: 'point', instructions: '지시선 끝점을 지정하십시오.' },
{ kind: 'point', prompt: { text: '피쳐 위치 지정' } },
{ kind: 'point', prompt: { text: '지시선 끝점 지정' } },
],
commit: (input) => {
const [feature, leaderEnd] = input.points();
@@ -1,27 +1,55 @@
/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */
/**
* 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절).
* 오토캐드 물음 차례(337 설계 1-2) — DIMLINEAR · DIMALIGNED = 원점 둘(빈 Enter = 객체 선택) → 치수선 위치
* [문자(T)/수평(H)/수직(V)] · 한 번 뒤 끝 · DIMCONTINUE · DIMBASELINE = 원점 되풀이 [명령 취소(U)] · 빈 Enter 끝.
*/
import { Circle, Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { EntityName } from '../../entities/Entity';
import { assign, createMachine } from 'xstate';
import { type OptionSpec, type PromptSpec, OPT, SELECT_OBJECTS } from '../../commands/prompt';
import { logLine } from '../../commands/run-command';
import { EntityName, type Entity } from '../../entities/Entity';
import { MeasurementEntity } from '../../entities/MeasurementEntity';
import type { PolyLineEntity } from '../../entities/PolyLineEntity';
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
import { getPointFromEvent } from '../../helpers/get-point-from-event';
import { notice } from '../../inputController/prompt-input';
import {
addEntities,
getActiveLayerId,
setAngleGuideOriginPoint,
setSelectedEntityIds,
setShouldDrawHelpers,
undo,
} from '../../state';
import { Tool } from '../../tools';
import { styled } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
import { END_STATE, type PointInputEvent, type StateEvent, type ToolContext } from '../tool.types';
import { getLastDimension, rememberDimension } from './annotation.helpers';
/** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */
function makeDimension(start: Point, end: Point, offset: Point): MeasurementEntity {
function makeDimension(start: Point, end: Point, offset: Point, text?: string): MeasurementEntity {
const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset));
if (text && text !== '<>') dimension.textOverride = text;
rememberDimension(dimension);
return dimension;
}
type Fixed = 'H' | 'V' | null;
/**
* 선형 치수 — 치수선을 놓은 방향으로 수평/수직을 고른다.
* 선형 치수 — 수평(H) · 수직(V) 고정이면 그 방향, 아니면 치수선을 놓은 방향으로 고른다.
* 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다).
*/
function projectForLinear(start: Point, end: Point, offset: Point): [Point, Point] {
function projectForLinear(
start: Point,
end: Point,
offset: Point,
fixed: Fixed = null
): [Point, Point] {
if (fixed === 'H') return [start, new Point(end.x, start.y)];
if (fixed === 'V') return [start, new Point(start.x, end.y)];
const horizontalSpan = Math.abs(end.x - start.x);
const verticalSpan = Math.abs(end.y - start.y);
const offsetIsVertical =
@@ -32,89 +60,268 @@ function projectForLinear(start: Point, end: Point, offset: Point): [Point, Poin
return [start, new Point(start.x, end.y)];
}
export const dimLinearToolStateMachine = createSequenceTool({
tool: Tool.DIMLINEAR,
steps: [
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length !== 2) return [];
const [start, end] = projectForLinear(points[0], points[1], input.cursor);
return [new MeasurementEntity(getActiveLayerId(), start, end, input.cursor)];
},
commit: (input) => {
const [first, second, offset] = input.points();
const [start, end] = projectForLinear(first, second, offset);
addEntities([makeDimension(start, end, offset)], true);
},
});
export const dimAlignedToolStateMachine = createSequenceTool({
tool: Tool.DIMALIGNED,
steps: [
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length !== 2) return [];
return [new MeasurementEntity(getActiveLayerId(), points[0], points[1], input.cursor)];
},
commit: (input) => {
const [start, end, offset] = input.points();
addEntities([makeDimension(start, end, offset)], true);
},
});
export const dimBaselineToolStateMachine = createSequenceTool({
tool: Tool.DIMBASELINE,
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
commit: (input) => {
const previous = getLastDimension();
if (!previous) {
toast.warn('먼저 치수를 하나 작성하십시오.');
return;
}
const start = previous.getStartPoint();
const offset = previous.getOffsetPoint();
// 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다
const spacing = offset.distanceTo(start)[0] * 0.35 || 10;
const direction = new Point(offset.x - start.x, offset.y - start.y);
const length = Math.hypot(direction.x, direction.y) || 1;
const nextOffset = new Point(
offset.x + (direction.x / length) * spacing,
offset.y + (direction.y / length) * spacing
/** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */
function endsOf(entity: Entity, pick: Point): [Point, Point] | null {
let target = entity;
if (entity.getType() === EntityName.PolyLine) {
const pieces = (entity as PolyLineEntity).getEntities();
target = pieces.reduce(
(best, piece) =>
(piece.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY) <
(best.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY)
? piece
: best,
pieces[0] ?? entity
);
addEntities([makeDimension(start, input.point(0), nextOffset)], true);
},
});
}
const points = sampleEntityPoints(target);
if (points.length < 2) return null;
return [points[0], points[points.length - 1]];
}
export const dimContinueToolStateMachine = createSequenceTool({
tool: Tool.DIMCONTINUE,
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
commit: (input) => {
const previous = getLastDimension();
if (!previous) {
toast.warn('먼저 치수를 하나 작성하십시오.');
return;
}
addEntities(
[makeDimension(previous.getEndPoint(), input.point(0), previous.getOffsetPoint())],
true
);
},
const option = (key: string, kw: string, label: string): OptionSpec => ({
key,
kw,
en: key,
label,
});
/** 빈 Enter = 객체 선택 — 숨은 옵션(그림은 <객체 선택>) */
const OBJECT = { ...option('OBJECT', 'OBJECT', '객체 선택'), hidden: true as const };
const HORIZONTAL = option('HORIZONTAL', 'H', '수평');
const VERTICAL = option('VERTICAL', 'V', '수직');
const MTEXT = option('MTEXT', 'M', '여러 줄 문자');
const ANGLE = option('ANGLE', 'A', '각도');
const ROTATED = option('ROTATED', 'R', '회전됨');
const SELECT = option('SELECT', 'S', '선택');
/** 원점 둘 또는 객체 끝 */
function originsOf(input: SequenceInput): [Point, Point] | null {
if (input.has('obj')) {
const entity = input.get<Entity>('obj');
return endsOf(entity, input.pick(input.values.indexOf(entity)));
}
if (!input.has('p2')) return null;
return [input.get<Point>('p1'), input.get<Point>('p2')];
}
/** 수평 · 수직 고정 — 명령 하나 동안(확정 · Esc 에서 풂) */
let fixed: Fixed = null;
/** DIMLINEAR · DIMALIGNED 공통 물음 */
function linearSteps(tool: Tool, aligned: boolean) {
const measure = (input: SequenceInput, offset: Point): [Point, Point] | null => {
const origins = originsOf(input);
if (!origins) return null;
return aligned ? origins : projectForLinear(origins[0], origins[1], offset, fixed);
};
const measured = (input: SequenceInput): string | null => {
const origins = originsOf(input);
if (!origins) return null;
const [a, b] = origins;
const wide = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y);
const [start, end] = aligned ? origins : projectForLinear(a, b, b, fixed ?? (wide ? 'H' : 'V'));
return new MeasurementEntity(getActiveLayerId(), start, end, end).getMeasuredText();
};
return createSequenceTool({
tool,
steps: [
{
kind: 'point',
id: 'p1',
prompt: {
text: '첫 번째 치수보조선 원점 지정 또는',
options: [OBJECT],
default: { kind: 'option', key: 'OBJECT', label: '객체 선택' },
},
options: { OBJECT: { mode: 'object', next: 'obj' } },
},
{
kind: 'entity',
id: 'obj',
when: (input) => input.mode === 'object' && !input.has('obj'),
next: 'place',
prompt: { text: '치수 기입할 객체 선택' },
},
{
kind: 'point',
id: 'p2',
when: (input) => !input.has('obj'),
prompt: { text: '두 번째 치수보조선 원점 지정' },
},
{
kind: 'text',
id: 'txt',
when: (input) => input.mode === 'text',
next: 'place',
defaultValue: '<>',
prompt: (input) => {
const value = measured(input);
return {
text: '치수 문자 입력',
...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }),
};
},
},
{
kind: 'point',
id: 'place',
prompt: {
text: '치수선의 위치 지정',
options: aligned ? [OPT.TEXT] : [OPT.TEXT, HORIZONTAL, VERTICAL],
absent: aligned ? [MTEXT, ANGLE] : [MTEXT, ANGLE, ROTATED],
},
options: {
TEXT: { mode: 'text', next: 'txt' },
...(aligned
? {}
: {
HORIZONTAL: {
run: () => {
fixed = 'H';
},
},
VERTICAL: {
run: () => {
fixed = 'V';
},
},
}),
},
},
],
preview: (input) => {
if (input.has('place') || !originsOf(input)) return [];
const span = measure(input, input.cursor);
if (!span) return [];
const ghost = new MeasurementEntity(getActiveLayerId(), span[0], span[1], input.cursor);
if (input.has('txt')) ghost.textOverride = input.get<string>('txt');
return [ghost];
},
commit: (input) => {
const offset = input.get<Point>('place');
const span = measure(input, offset);
fixed = null;
if (!span) {
toast.warn('치수를 넣을 수 없는 객체입니다.');
return;
}
const dimension = makeDimension(
span[0],
span[1],
offset,
input.has('txt') ? input.get<string>('txt') : undefined
);
addEntities([dimension], true);
logLine(`치수 문자 = ${dimension.getLabel()}`);
},
onCancel: () => {
fixed = null;
},
});
}
export const dimLinearToolStateMachine = linearSteps(Tool.DIMLINEAR, false);
export const dimAlignedToolStateMachine = linearSteps(Tool.DIMALIGNED, true);
/** 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다 */
function baselineDimension(previous: MeasurementEntity, point: Point): MeasurementEntity {
const start = previous.getStartPoint();
const offset = previous.getOffsetPoint();
const spacing = offset.distanceTo(start)[0] * 0.35 || 10;
const direction = new Point(offset.x - start.x, offset.y - start.y);
const length = Math.hypot(direction.x, direction.y) || 1;
const nextOffset = new Point(
offset.x + (direction.x / length) * spacing,
offset.y + (direction.y / length) * spacing
);
return makeDimension(start, point, nextOffset);
}
const continueDimension = (previous: MeasurementEntity, point: Point): MeasurementEntity =>
makeDimension(previous.getEndPoint(), point, previous.getOffsetPoint());
interface ChainContext extends ToolContext {
/** 명령 시작 때 직전 치수 · 이 명령이 만든 치수(U 가 뒤에서 뺌) */
first: MeasurementEntity | null;
made: MeasurementEntity[];
}
type ChainCtx = { context: ChainContext; event: StateEvent };
const CHAIN_PROMPT: PromptSpec = {
text: '두 번째 치수보조선 원점 지정',
options: [OPT.UNDO],
absent: [SELECT],
};
/** DIMCONTINUE · DIMBASELINE — 앞 치수에 이어 원점 되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter · Esc = 끝 */
function chainTool(
tool: Tool,
build: (previous: MeasurementEntity, point: Point) => MeasurementEntity
) {
const base = (context: ChainContext) => context.made[context.made.length - 1] ?? context.first;
const hasBase = ({ context }: ChainCtx) => !!base(context);
const place = {
guard: hasBase,
actions: assign(({ context, event }: ChainCtx) => {
const previous = base(context) as MeasurementEntity;
const point = getPointFromEvent(previous.getEndPoint(), event as PointInputEvent);
const dimension = build(previous, point);
addEntities([dimension], true);
logLine(`치수 문자 = ${dimension.getLabel()}`);
setAngleGuideOriginPoint(dimension.getEndPoint());
return { made: [...context.made, dimension] };
}),
};
const noBase = {
target: END_STATE,
actions: () => notice('먼저 치수를 하나 작성하십시오.'),
};
return createMachine({
types: {} as { context: ChainContext; events: StateEvent },
context: () => ({ type: tool, first: getLastDimension(), made: [] }),
initial: 'PICK',
states: {
PICK: {
meta: { kind: 'point', prompt: CHAIN_PROMPT },
entry: ({ context }: ChainCtx) => {
setShouldDrawHelpers(true);
setAngleGuideOriginPoint(base(context)?.getEndPoint() ?? null);
},
on: {
MOUSE_CLICK: [place, noBase],
ABSOLUTE_POINT_INPUT: [place, noBase],
RELATIVE_POINT_INPUT: [place, noBase],
NUMBER_INPUT: [place, noBase],
'OPTION.UNDO': {
actions: assign(({ context }: ChainCtx) => {
if (!context.made.length) return {};
undo();
const made = context.made.slice(0, -1);
const previous = made[made.length - 1] ?? context.first;
if (previous) rememberDimension(previous);
setAngleGuideOriginPoint(previous?.getEndPoint() ?? null);
return { made };
}),
},
ENTER: END_STATE,
ESC: END_STATE,
},
},
[END_STATE]: { type: 'final' },
// biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음)
} as Record<string, any>,
});
}
export const dimBaselineToolStateMachine = chainTool(Tool.DIMBASELINE, baselineDimension);
export const dimContinueToolStateMachine = chainTool(Tool.DIMCONTINUE, continueDimension);
export const qDimToolStateMachine = createSequenceTool({
tool: Tool.QDIM,
helpers: false,
steps: [
{ kind: 'selection', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' },
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
{ kind: 'selection', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } },
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
],
commit: (input) => {
const offset = input.point(1);
@@ -140,8 +347,8 @@ export const dimAutoToolStateMachine = createSequenceTool({
tool: Tool.DIM,
helpers: false,
steps: [
{ kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' },
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
{ kind: 'entity', prompt: { text: '치수 기입할 객체 선택' } },
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
],
commit: (input) => {
const entity = input.entity(0);
@@ -1,9 +1,9 @@
/** 문자 명령 — 여러 줄 문자·단일 행 문자·편집·찾기 (조사표 5절 문자 패널) */
import { Point } from '@flatten-js/core';
import { Point, Vector } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { getAnnotationScale } from '../../commands/dim-settings';
import { type OptionSpec, type PromptSpec, recall } from '../../commands/prompt';
import { EntityName } from '../../entities/Entity';
import type { Entity } from '../../entities/Entity';
import type { TextEntity } from '../../entities/TextEntity';
import {
addEntities,
@@ -13,7 +13,7 @@ import {
setEntities,
} from '../../state';
import { Tool } from '../../tools';
import { textEntity } from '../factories/entity-factory';
import { polyLineEntity, textEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
/** AutoCAD 여러 줄 문자의 줄바꿈 표기(\P)와 \n을 모두 받는다 */
@@ -24,42 +24,156 @@ const splitLines = (text: string): string[] =>
.map((line) => line.trim())
.filter((line) => line.length > 0);
function buildTextLines(lines: string[], basePoint: Point): Entity[] {
const lineHeight = getActiveTextStyle().fontSize * getAnnotationScale() * 1.35;
const groupId = lines.length > 1 ? crypto.randomUUID() : undefined;
return lines.map((line, index) => {
const entity = textEntity(line, new Point(basePoint.x, basePoint.y - lineHeight * index), {
textAlign: 'left',
fontSize: getActiveTextStyle().fontSize * getAnnotationScale(),
});
entity.groupId = groupId;
return entity;
});
}
/** 줄 간격 = 글자 높이 × 1.35 */
const LINE_GAP = 1.35;
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
/** 지금 글자 크기 — TEXTSIZE 처음 값 */
const textSize = () => getActiveTextStyle().fontSize * getAnnotationScale();
const TEXT_HEIGHT = (): PromptSpec => ({
text: '높이 지정',
default: { kind: 'value', remember: 'TEXTSIZE', initial: textSize() },
number: { positive: true },
});
/** 글 물음 — 빈 Enter = 끝(기본은 안 그림) */
const typing = (text: string): PromptSpec => ({ text, default: { kind: 'option', key: 'DONE' } });
/** 한 줄 글 — 높이 · 회전 각(도) */
const placeText = (label: string, point: Point, height: number, angle: number) => {
const radians = (angle * Math.PI) / 180;
return textEntity(label, point, {
textAlign: 'left',
fontSize: height,
textDirection: new Vector(Math.cos(radians), Math.sin(radians)),
});
};
type TextLine = { point: Point; height: number; angle: number; start: Point | null };
/** TEXT 마지막 줄(세션) — 다음 줄 · 시작점 Enter 가 그 아래로 이어 씀 */
let lastLine: TextLine | null = null;
const below = ({ point, height, angle }: TextLine) => {
const radians = (angle * Math.PI) / 180;
const gap = height * LINE_GAP;
return new Point(point.x + Math.sin(radians) * gap, point.y - Math.cos(radians) * gap);
};
/**
* TEXT — 「문자의 시작점 지정:」 → 「높이 지정 <h>:」 → 「문자의 회전 각도 지정 <0>:」 → 줄마다 「문자 입력:」.
* 줄 Enter = 그 줄 만들고 아래 새 줄 · 빈 줄 Enter = 끝 · 시작점 Enter = 마지막 글 아래(높이 · 각 안 물음).
*/
export const textToolStateMachine = createSequenceTool({
tool: Tool.TEXT,
loopFrom: 'line',
steps: [
{ kind: 'point', instructions: '문자의 시작점을 지정하십시오.' },
{ kind: 'text', instructions: '문자를 입력하십시오.' },
{
kind: 'point',
id: 'start',
prompt: () => ({
text: '문자의 시작점 지정',
absent: [opt('JUSTIFY', 'J', '자리맞추기'), opt('STYLE', 'S', '스타일')],
...(lastLine ? { default: { kind: 'option', key: 'CONTINUE' } as const } : {}),
}),
options: { CONTINUE: { mode: 'continue', next: 'line' } },
},
{
kind: 'number',
id: 'height',
when: (input) => input.mode !== 'continue',
prompt: TEXT_HEIGHT,
},
{
kind: 'number',
id: 'angle',
when: (input) => input.mode !== 'continue',
prompt: {
text: '문자의 회전 각도 지정',
default: { kind: 'value', remember: 'TEXT.ANGLE', initial: 0, format: 'ang' },
},
},
{ kind: 'text', id: 'line', prompt: typing('문자 입력'), options: { DONE: 'end' } },
],
commit: (input) => {
const lines = splitLines(input.text(1));
if (!lines.length) return;
addEntities(buildTextLines(lines.slice(0, 1), input.point(0)), true);
const start = input.has('start') ? input.get<Point>('start') : null;
const previous = start ? null : lastLine;
const height = previous?.height ?? input.get<number>('height');
const angle = previous?.angle ?? input.get<number>('angle');
// 같은 명령의 다음 줄 · 시작점 Enter = 마지막 줄 아래
const follow = lastLine && (!start || lastLine.start === start) ? lastLine : null;
const point = follow ? below(follow) : (start as Point);
addEntities([placeText(String(input.get('line')), point, height, angle)], true);
lastLine = { point, height, angle, start };
},
});
/**
* MTEXT — 「첫 번째 구석 지정:」 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 「문자 입력:」(줄바꿈 \P).
* 첫 줄 = 상자 왼쪽 위 · 줄 감기 없음(줄은 \P 로만).
*/
export const mtextToolStateMachine = createSequenceTool({
tool: Tool.MTEXT,
steps: [
{ kind: 'point', instructions: '여러 줄 문자의 첫 코너를 지정하십시오.' },
{ kind: 'text', instructions: '문자를 입력하십시오 (줄바꿈은 \\P).' },
{ kind: 'point', id: 'corner', prompt: { text: '첫 번째 구석 지정' } },
{
kind: 'point',
id: 'opposite',
prompt: {
text: '반대 구석 지정',
options: [opt('HEIGHT', 'H', '높이'), opt('WIDTH', 'W', '폭')],
absent: [
opt('JUSTIFY', 'J', '자리맞추기'),
opt('LINESPACING', 'L', '행 간격'),
opt('ROTATION', 'R', '회전'),
opt('STYLE', 'S', '스타일'),
opt('COLUMNS', 'C', '열'),
],
},
options: { HEIGHT: { mode: 'H', next: 'height' }, WIDTH: { mode: 'W', next: 'width' } },
},
{
kind: 'number',
id: 'height',
when: (input) => !input.has('opposite'),
next: 'opposite',
prompt: TEXT_HEIGHT,
},
{
kind: 'point',
id: 'width',
when: (input) => !input.has('opposite'),
prompt: { text: '폭 지정', number: { positive: true } },
},
{
kind: 'text',
id: 'text',
prompt: typing('문자 입력 (줄바꿈 \\P)'),
options: { DONE: 'end' },
},
],
preview: (input) => {
const corner = input.get<Point>('corner');
if (!corner || input.has('opposite') || input.mode === 'W') return [];
const { x, y } = input.cursor;
const box = [corner, new Point(x, corner.y), new Point(x, y), new Point(corner.x, y), corner];
const outline = polyLineEntity(box);
return outline ? [outline] : [];
},
commit: (input) => {
const lines = splitLines(input.text(1));
const lines = splitLines(String(input.get('text')));
if (!lines.length) return;
addEntities(buildTextLines(lines, input.point(0)), true);
const corner = input.get<Point>('corner');
const opposite = input.has('opposite') ? input.get<Point>('opposite') : corner;
const height = input.has('height')
? input.get<number>('height')
: (recall<number>('TEXTSIZE') ?? textSize());
const left = Math.min(corner.x, opposite.x);
const top = Math.max(corner.y, opposite.y) - height;
const groupId = lines.length > 1 ? crypto.randomUUID() : undefined;
const entities = lines.map((line, index) => {
const entity = placeText(line, new Point(left, top - height * LINE_GAP * index), height, 0);
entity.groupId = groupId;
return entity;
});
addEntities(entities, true);
},
});
@@ -0,0 +1,169 @@
/** ARRAY 계산 · 물음(337 라 · 설계 1-3) — 직사각형 열 × 행 · 원형 항목 · 채울 각도 · 항목 회전 */
import type { Box, Point } from '@flatten-js/core';
import type { OptionSpec, PromptSpec } from '../commands/prompt';
import type { Entity } from '../entities/Entity';
import { cloneKeepingLook } from './move-tool.helpers';
import type { PickedContext } from './transform.helpers';
export interface ArrayValues extends PickedContext {
columns: number;
rows: number;
columnSpacing: number;
rowSpacing: number;
center: Point | null;
items: number;
/** 채울 각도(도 · + 반시계) */
fill: number;
rotateItems: boolean;
}
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
key,
kw,
en,
label,
});
export const ARRAY_OPT = {
RECTANGULAR: opt('RECTANGULAR', 'R', '직사각형'),
POLAR: opt('POLAR', 'PO', '원형'),
PATH: opt('PATH', 'PA', '경로'),
COUNT: opt('COUNT', 'COU', '개수'),
SPACING: opt('SPACING', 'S', '간격두기'),
COLUMNS: opt('COLUMNS', 'COL', '열'),
ROWS: opt('ROWS', 'R', '행'),
POLAR_ROWS: opt('ROWS', 'ROW', '행'),
ITEMS: opt('ITEMS', 'I', '항목'),
ANGLE: opt('ANGLE', 'A', '사이의 각도'),
FILL: opt('FILL', 'F', '채울 각도'),
ROTATE: opt('ROTATE', 'ROT', '항목 회전'),
EXIT: opt('EXIT', 'X', '종료'),
ASSOCIATIVE: opt('ASSOCIATIVE', 'AS', '연관'),
BASE: opt('BASE', 'B', '기준점'),
LEVELS: opt('LEVELS', 'L', '수준'),
AXIS: opt('AXIS', 'A', '회전축'),
};
const O = ARRAY_OPT;
const COUNT_RULE = { int: true, min: 1, max: 32767 } as const;
/** 항목 사이 각 — 채울 각이 한 바퀴면 항목 수로 나눔 · 아니면 사이 수로 */
export const angleBetween = (values: Pick<ArrayValues, 'items' | 'fill'>): number => {
if (Math.abs(values.fill) >= 360) return values.fill / values.items;
return values.items > 1 ? values.fill / (values.items - 1) : 0;
};
const value = (text: string, remember: string, initial: number, format?: 'int' | 'ang') => ({
text,
default: { kind: 'value' as const, remember, initial, ...(format ? { format } : {}) },
});
/** 물음 — 함수 꼴은 지금 값(빈 context 면 처음 값) */
export const ARRAY_PROMPT = {
type: (fallback: string): PromptSpec => ({
text: '배열 유형 입력',
options: [O.RECTANGULAR, O.POLAR],
absent: [O.PATH],
default: { kind: 'option', key: fallback },
}),
center: { text: '배열의 중심점 지정', absent: [O.BASE, O.AXIS] } satisfies PromptSpec,
editRect: {
text: '그립을 선택하여 배열을 편집하거나',
options: [O.COUNT, O.SPACING, O.COLUMNS, O.ROWS, O.EXIT],
absent: [O.ASSOCIATIVE, O.BASE, O.LEVELS],
default: { kind: 'option', key: O.EXIT.key },
} satisfies PromptSpec,
editPolar: {
text: '그립을 선택하여 배열을 편집하거나',
options: [O.ITEMS, O.ANGLE, O.FILL, O.ROTATE, O.EXIT],
absent: [O.ASSOCIATIVE, O.BASE, O.POLAR_ROWS, O.LEVELS],
default: { kind: 'option', key: O.EXIT.key },
} satisfies PromptSpec,
columns: (v: Partial<ArrayValues>): PromptSpec => ({
...value('열 수 입력', 'ARRAY.COLS', v.columns ?? 4, 'int'),
number: COUNT_RULE,
}),
rows: (v: Partial<ArrayValues>): PromptSpec => ({
...value('행 수 입력', 'ARRAY.ROWS', v.rows ?? 3, 'int'),
number: COUNT_RULE,
}),
columnSpacing: (v: Partial<ArrayValues>): PromptSpec =>
value('열 사이의 거리 지정', 'ARRAY.COLDIST', v.columnSpacing ?? 1),
rowSpacing: (v: Partial<ArrayValues>): PromptSpec =>
value('행 사이의 거리 지정', 'ARRAY.ROWDIST', v.rowSpacing ?? 1),
items: (v: Partial<ArrayValues>): PromptSpec => ({
...value('배열의 항목 수 입력', 'ARRAY.ITEMS', v.items ?? 6, 'int'),
number: COUNT_RULE,
}),
angle: (v: Partial<ArrayValues>): PromptSpec =>
value(
'항목 사이의 각도 지정',
'ARRAY.ANGLE',
angleBetween({ items: v.items ?? 6, fill: v.fill ?? 360 }),
'ang'
),
fill: (v: Partial<ArrayValues>): PromptSpec =>
value('채울 각도 지정(+=ccw, -=cw)', 'ARRAY.FILL', v.fill ?? 360, 'ang'),
rotateItems: (v: Partial<ArrayValues>): PromptSpec => ({
text: '배열된 항목을 회전하시겠습니까?',
options: [opt('YES', 'Y', '예'), opt('NO', 'N', '아니오')],
default: { kind: 'option', key: v.rotateItems === false ? 'NO' : 'YES' },
}),
};
/** 고른 것 범위 — 처음 간격 = 폭 · 높이의 1.5 배(0 이면 다른 쪽 · 1) */
export function initialSpacing(originals: Entity[]): { column: number; row: number } {
const box = originals.reduce<Box | null>((all, entity) => {
const next = entity.getBoundingBox();
return all ? all.merge(next) : next;
}, null);
const width = box ? box.xmax - box.xmin : 0;
const height = box ? box.ymax - box.ymin : 0;
const fallback = Math.max(width, height) || 1;
return { column: 1.5 * (width || fallback), row: 1.5 * (height || fallback) };
}
/** 고른 것 범위 가운데 — 항목 회전 끔일 때 옮김 기준 */
function selectionCenter(originals: Entity[]): Point | null {
const boxes = originals.map((entity) => entity.getBoundingBox());
if (!boxes.length) return null;
return boxes.reduce((all, next) => all.merge(next)).center;
}
/** 직사각형 — (0,0) 원본 빼고 열 × 행 사본 */
export function rectangularCopies(values: ArrayValues): Entity[] {
const copies: Entity[] = [];
for (let row = 0; row < values.rows; row++) {
for (let column = 0; column < values.columns; column++) {
if (!row && !column) continue;
for (const entity of values.originals) {
const copy = cloneKeepingLook(entity);
copy.move(column * values.columnSpacing, row * values.rowSpacing);
copies.push(copy);
}
}
}
return copies;
}
/** 원형 — 중심 둘레 항목 수 − 1 사본 · 항목 회전 끔이면 돌지 않고 자리만 */
export function polarCopies(values: ArrayValues, center = values.center): Entity[] {
if (!center) return [];
const step = (angleBetween(values) * Math.PI) / 180;
const anchor = selectionCenter(values.originals);
const copies: Entity[] = [];
for (let index = 1; index < values.items; index++) {
const radians = step * index;
for (const entity of values.originals) {
const copy = cloneKeepingLook(entity);
if (values.rotateItems || !anchor) {
copy.rotate(center, radians);
} else {
const moved = anchor.rotate(radians, center);
copy.move(moved.x - anchor.x, moved.y - anchor.y);
}
copies.push(copy);
}
}
return copies;
}
@@ -1,369 +1,293 @@
import { type Point, Vector } from '@flatten-js/core';
import { assign, createMachine, sendTo } from 'xstate';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts';
import type { Entity } from '../entities/Entity';
import { LineEntity } from '../entities/LineEntity.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import {
addEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
/**
* ARRAY(337 라 · 설계 1-3) — 「객체 선택:」 → 「배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:」(ARRAYTYPE 기억) →
* 직사각형: 미리보기 4열 × 3행 → 「그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:」 되풀이 ·
* 원형: 「배열의 중심점 지정:」 → 미리보기 6개 360° → 「… [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:」 되풀이.
* 종료(X) · 빈 Enter = 만듦 · Esc = 안 만듦 · 처음 간격 = 고른 것 범위 폭 · 높이의 1.5 배.
*/
import type { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { recall, remember } from '../commands/prompt';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { addEntities, setGhostHelperEntities, setShouldDrawHelpers } from '../state';
import { Tool } from '../tools';
import { CopyAction } from './copy-tool.ts';
import { cloneKeepingLook } from './move-tool.helpers.ts';
import { selectToolStateMachine } from './select-tool.ts';
import type {
AbsolutePointInputEvent,
DrawEvent,
MouseClickEvent,
NumberInputEvent,
StateEvent,
TextInputEvent,
ToolContext,
import {
ARRAY_OPT,
ARRAY_PROMPT,
type ArrayValues,
angleBetween,
initialSpacing,
polarCopies,
rectangularCopies,
} from './array-tool.helpers';
import {
type DrawEvent,
END_STATE,
type NumberInputEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
import { endState, selectionState, startState } from './transform.helpers';
export enum ArrayState {
INIT = 'INIT',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
CHECK_SELECTION = 'CHECK_SELECTION',
ASK_COPY_MODE = 'ASK_COPY_MODE',
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
EXECUTE_COPY = 'EXECUTE_COPY',
WAITING_FOR_TYPE = 'WAITING_FOR_TYPE',
WAITING_FOR_CENTER = 'WAITING_FOR_CENTER',
EDIT_RECTANGULAR = 'EDIT_RECTANGULAR',
EDIT_POLAR = 'EDIT_POLAR',
WAITING_FOR_COLUMNS = 'WAITING_FOR_COLUMNS',
WAITING_FOR_ROWS = 'WAITING_FOR_ROWS',
WAITING_FOR_COLUMN_SPACING = 'WAITING_FOR_COLUMN_SPACING',
WAITING_FOR_ROW_SPACING = 'WAITING_FOR_ROW_SPACING',
WAITING_FOR_ITEMS = 'WAITING_FOR_ITEMS',
WAITING_FOR_ANGLE = 'WAITING_FOR_ANGLE',
WAITING_FOR_FILL = 'WAITING_FOR_FILL',
WAITING_FOR_ROTATE_ITEMS = 'WAITING_FOR_ROTATE_ITEMS',
}
export enum ArrayAction {
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
PERFORM_COPY = 'PERFORM_COPY',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
/** 편집 옵션이 이어 묻는 차례 — 개수 = 열 → 행 · 간격두기 = 열 거리 → 행 거리 · 열 = 수 → 거리 · 행 = 수 → 거리 */
type Flow = 'COUNT' | 'SPACING' | 'COLUMNS' | 'ROWS' | null;
export interface ArrayContext extends ToolContext, ArrayValues {
flow: Flow;
}
export interface ArrayContext extends ToolContext {
copyMode: CopyMode | null;
startDistanceVector: Point | null;
endDistanceVector: Point | null;
pivotPoint: Point | null;
angleStep: number | null;
numberOfCopies: number;
}
type Ctx = { context: ArrayContext; event: StateEvent };
enum CopyMode {
LINEAR = 'LINEAR',
RADIAL = 'RADIAL',
}
const S = ArrayState;
const O = ARRAY_OPT;
const esc = { ESC: { target: END_STATE } };
const numberOf = (event: StateEvent) => (event as NumberInputEvent).value;
const initialArrayContext: ArrayContext = {
copyMode: null,
startDistanceVector: null,
endDistanceVector: null,
pivotPoint: null,
angleStep: null,
numberOfCopies: 0,
type: Tool.ARRAY,
/** 물음 기본값 = 지금 값(빈 Enter 가 지금 값을 다시 보냄) */
const syncRect = (values: ArrayValues) => {
remember('ARRAY.COLS', values.columns);
remember('ARRAY.ROWS', values.rows);
remember('ARRAY.COLDIST', values.columnSpacing);
remember('ARRAY.ROWDIST', values.rowSpacing);
};
const syncPolar = (values: ArrayValues) => {
remember('ARRAY.ITEMS', values.items);
remember('ARRAY.FILL', values.fill);
remember('ARRAY.ANGLE', angleBetween(values));
};
export const arrayToolStateMachine = createMachine(
{
types: {} as {
context: ArrayContext;
events: StateEvent;
const showRect = ({ context }: Ctx) => {
syncRect(context);
setGhostHelperEntities(rectangularCopies(context));
};
const showPolar = ({ context }: Ctx) => {
syncPolar(context);
setGhostHelperEntities(polarCopies(context));
};
const create = (copies: (values: ArrayValues) => unknown[]) => ({
actions: ({ context }: Ctx) => {
const made = copies(context) as Parameters<typeof addEntities>[0];
if (made.length) addEntities(made, true);
},
target: END_STATE,
});
/** 숫자 물음 — 받아서 assign · 흐름 따라 다음 */
const numberStep = (
prompt: unknown,
take: (value: number, context: ArrayContext) => Partial<ArrayContext> | null,
next: { flow?: Flow; target: string }[]
) => ({
meta: { kind: 'number', prompt },
on: {
NUMBER_INPUT: [
...next.map(({ flow, target }) => ({
guard: ({ context, event }: Ctx) =>
!!take(numberOf(event), context) && (flow === undefined || context.flow === flow),
actions: assign(({ context, event }: Ctx) => take(numberOf(event), context) ?? {}),
target,
})),
{ actions: () => noticeLater('값이 범위를 벗어남.') },
],
...esc,
},
});
const noticeLater = (text: string) => {
void import('../inputController/prompt-input').then(({ notice }) => notice(text));
};
const fillOk = (fill: number) => fill !== 0 && Math.abs(fill) <= 360;
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
const states: Record<string, any> = {
[S.INIT]: startState(S.WAITING_FOR_TYPE),
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_TYPE),
[S.WAITING_FOR_TYPE]: {
meta: {
kind: 'keyword',
prompt: () => ARRAY_PROMPT.type(recall<string>('ARRAYTYPE') ?? O.RECTANGULAR.key),
},
context: initialArrayContext,
initial: ArrayState.INIT,
states: {
[ArrayState.INIT]: {
description: 'Initializing the array copy tool',
always: {
actions: ArrayState.INIT,
target: ArrayState.CHECK_SELECTION,
},
},
[ArrayState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return initialArrayContext;
}),
target: ArrayState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
},
},
[ArrayState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: ArrayState.ASK_COPY_MODE,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: ArrayState.WAITING_FOR_SELECTION,
},
on: {
'OPTION.RECTANGULAR': {
actions: [
() => remember('ARRAYTYPE', O.RECTANGULAR.key),
assign(({ context }: Ctx) => {
const spacing = initialSpacing(context.originals);
return {
columns: recall<number>('ARRAY.COLS') ?? 4,
rows: recall<number>('ARRAY.ROWS') ?? 3,
columnSpacing: spacing.column,
rowSpacing: spacing.row,
};
}),
],
target: S.EDIT_RECTANGULAR,
},
[ArrayState.ASK_COPY_MODE]: {
meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' },
on: {
TEXT_INPUT: {
actions: assign(({ event }) => {
return {
copyMode:
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
};
}),
target: ArrayState.ASK_NUMBER_OF_COPIES,
},
ENTER: {
actions: assign(() => ({
copyMode: CopyMode.LINEAR,
})),
target: ArrayState.ASK_NUMBER_OF_COPIES,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.ASK_NUMBER_OF_COPIES]: {
meta: { instructions: 'Enter number of copies' },
on: {
NUMBER_INPUT: [
{
guard: ({ context }) => {
return context.copyMode === CopyMode.LINEAR;
},
actions: assign(({ event }) => ({
numberOfCopies: (event as NumberInputEvent).value,
})),
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
},
{
guard: ({ context }) => {
return context.copyMode === CopyMode.RADIAL;
},
actions: assign(({ event }) => ({
numberOfCopies: (event as NumberInputEvent).value,
})),
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
},
],
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
meta: { instructions: 'Click to define the start of the distance vector' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
startDistanceVector: (event as AbsolutePointInputEvent).value,
})),
target: ArrayState.EXECUTE_COPY,
},
MOUSE_CLICK: {
actions: assign(({ event }) => ({
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
})),
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
meta: { instructions: 'Click to define the end of the distance vector' },
on: {
RELATIVE_POINT_INPUT: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
MOUSE_CLICK: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
DRAW: {
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
},
},
},
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
meta: { instructions: 'Click to set pivot point' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
pivotPoint: (event as AbsolutePointInputEvent).value,
})),
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
},
MOUSE_CLICK: {
actions: assign(({ event }) => ({
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
})),
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
meta: { instructions: 'Enter angle per step (in degrees)' },
on: {
NUMBER_INPUT: {
target: ArrayState.EXECUTE_COPY,
actions: assign(({ event }) => ({
angleStep: (event as NumberInputEvent).value,
})),
},
},
},
[ArrayState.EXECUTE_COPY]: {
entry: ArrayAction.PERFORM_COPY,
always: ArrayState.INIT,
'OPTION.POLAR': {
actions: [
() => remember('ARRAYTYPE', O.POLAR.key),
assign(() => ({
items: recall<number>('ARRAY.ITEMS') ?? 6,
fill: recall<number>('ARRAY.FILL') ?? 360,
rotateItems: true,
})),
],
target: S.WAITING_FOR_CENTER,
},
...esc,
},
},
{
actions: {
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
copyMode: null,
distanceVector: null,
pivotPoint: null,
angleStep: null,
numberOfCopies: 0,
selectedEntities: [],
};
}),
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
if (!context.startDistanceVector) {
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
}
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// Draw the array entities to show the result
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
// TODO
// // Draw a dashed line between the start move point and the current mouse location
const activeDistanceLine = new LineEntity(
getActiveLayerId(),
context.startDistanceVector,
endPointTemp
);
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeDistanceLine]);
[S.WAITING_FOR_CENTER]: {
meta: { kind: 'point', prompt: ARRAY_PROMPT.center },
entry: () => setShouldDrawHelpers(true),
on: {
DRAW: {
actions: ({ context, event }: Ctx) =>
setGhostHelperEntities(polarCopies(context, getPointFromEvent(null, event as DrawEvent))),
},
[ArrayAction.PERFORM_COPY]: ({ context }) => {
const resultEntities: Entity[] = [];
for (let i = 1; i <= context.numberOfCopies; i++) {
if (
context.copyMode === 'LINEAR' &&
context.startDistanceVector &&
context.endDistanceVector
) {
const distanceVector = new Vector(
context.endDistanceVector.x - context.startDistanceVector.x,
context.endDistanceVector.y - context.startDistanceVector.y
);
const x = distanceVector.x * i;
const y = distanceVector.y * i;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = cloneKeepingLook(entity);
clone.move(x, y);
return clone;
})
);
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
const angleRad = context.angleStep * i * TO_RADIANS;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = cloneKeepingLook(entity);
clone.rotate(context.pivotPoint as Point, angleRad);
return clone;
})
);
}
}
addEntities(resultEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[CopyAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return initialArrayContext;
}),
...Object.fromEntries(
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
type,
{
actions: assign(({ event }: Ctx) => ({
center: getPointFromEvent(null, event as PointInputEvent) as Point,
})),
target: S.EDIT_POLAR,
},
])
),
...esc,
},
}
);
},
[S.EDIT_RECTANGULAR]: {
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editRect },
entry: [() => setShouldDrawHelpers(false), assign({ flow: null }), showRect],
on: {
'OPTION.COUNT': {
actions: assign({ flow: 'COUNT' as Flow }),
target: S.WAITING_FOR_COLUMNS,
},
'OPTION.SPACING': {
actions: assign({ flow: 'SPACING' as Flow }),
target: S.WAITING_FOR_COLUMN_SPACING,
},
'OPTION.COLUMNS': {
actions: assign({ flow: 'COLUMNS' as Flow }),
target: S.WAITING_FOR_COLUMNS,
},
'OPTION.ROWS': { actions: assign({ flow: 'ROWS' as Flow }), target: S.WAITING_FOR_ROWS },
'OPTION.EXIT': create(rectangularCopies),
...esc,
},
},
[S.WAITING_FOR_COLUMNS]: numberStep(
ARRAY_PROMPT.columns,
(columns) => {
remember('ARRAY.COLS', columns);
return { columns };
},
[{ flow: 'COUNT', target: S.WAITING_FOR_ROWS }, { target: S.WAITING_FOR_COLUMN_SPACING }]
),
[S.WAITING_FOR_ROWS]: numberStep(
ARRAY_PROMPT.rows,
(rows) => {
remember('ARRAY.ROWS', rows);
return { rows };
},
[{ flow: 'ROWS', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
),
[S.WAITING_FOR_COLUMN_SPACING]: numberStep(
ARRAY_PROMPT.columnSpacing,
(columnSpacing) => ({ columnSpacing }),
[{ flow: 'SPACING', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
),
[S.WAITING_FOR_ROW_SPACING]: numberStep(
ARRAY_PROMPT.rowSpacing,
(rowSpacing) => ({ rowSpacing }),
[{ target: S.EDIT_RECTANGULAR }]
),
[S.EDIT_POLAR]: {
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editPolar },
entry: [() => setShouldDrawHelpers(false), showPolar],
on: {
'OPTION.ITEMS': { target: S.WAITING_FOR_ITEMS },
'OPTION.ANGLE': { target: S.WAITING_FOR_ANGLE },
'OPTION.FILL': { target: S.WAITING_FOR_FILL },
'OPTION.ROTATE': { target: S.WAITING_FOR_ROTATE_ITEMS },
'OPTION.EXIT': create(polarCopies),
...esc,
},
},
[S.WAITING_FOR_ITEMS]: numberStep(
ARRAY_PROMPT.items,
(items) => {
remember('ARRAY.ITEMS', items);
return { items };
},
[{ target: S.EDIT_POLAR }]
),
// 사이 각 → 채울 각 = 사이 각 × (항목 − 1)(같은 자리)
[S.WAITING_FOR_ANGLE]: numberStep(
ARRAY_PROMPT.angle,
(angle, context) => {
const fill = angle * Math.max(context.items - 1, 1);
return fillOk(fill) ? { fill } : null;
},
[{ target: S.EDIT_POLAR }]
),
[S.WAITING_FOR_FILL]: numberStep(
ARRAY_PROMPT.fill,
(fill) => {
if (!fillOk(fill)) return null;
remember('ARRAY.FILL', fill);
return { fill };
},
[{ target: S.EDIT_POLAR }]
),
[S.WAITING_FOR_ROTATE_ITEMS]: {
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.rotateItems },
on: {
'OPTION.YES': { actions: assign({ rotateItems: true }), target: S.EDIT_POLAR },
'OPTION.NO': { actions: assign({ rotateItems: false }), target: S.EDIT_POLAR },
...esc,
},
},
[END_STATE]: endState,
};
export const arrayToolStateMachine = createMachine({
types: {} as { context: ArrayContext; events: StateEvent },
context: {
originals: [],
columns: 4,
rows: 3,
columnSpacing: 1,
rowSpacing: 1,
center: null,
items: 6,
fill: 360,
rotateItems: true,
flow: null,
type: Tool.ARRAY,
},
initial: S.INIT,
states,
});
@@ -1,172 +1,229 @@
/**
* 원(CIRCLE) — 337 설계 1-1 · 오토캐드 차례.
* 「원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:」 → 「원의 반지름 지정 또는 [지름(D)] <r>:」 ·
* D → 「원의 지름 지정 <2r>:」 · 3P → 원 위 세 점 · 2P → 지름 두 끝점.
* 반지름 기억(CIRCLERAD · 처음 없음 → <> 안 그림 · 빈 Enter 아무 일 없음) · 누른 점 · 3P · 2P 반지름도 기억. 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { type PromptSpec, recall, remember } from '../commands/prompt';
import { CircleEntity } from '../entities/CircleEntity';
import type { Point } from '@flatten-js/core';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import { arcThroughThreePoints } from '../helpers/geometry/shape-points';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setActiveToolActor,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import { pointDistance } from '../helpers/distance-between-points';
import { LineState } from './line-tool.ts';
import { selectToolStateMachine } from './select-tool.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import { END, firstPointEvents, option, pointEvents } from './draw/draw-steps';
import { styled } from './factories/entity-factory';
import {
type DrawEvent,
END_STATE,
type NumberInputEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
export interface CircleContext extends ToolContext {
centerPoint: Point | null;
/** 3P · 2P 에서 이미 찍은 점 */
points: Point[];
threePoints: boolean;
}
export enum CircleState {
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
INIT = 'INIT',
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
WAITING_FOR_RADIUS = 'WAITING_FOR_RADIUS',
WAITING_FOR_DIAMETER = 'WAITING_FOR_DIAMETER',
THREE_POINTS = 'THREE_POINTS',
TWO_POINTS = 'TWO_POINTS',
}
export enum CircleAction {
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
const RADIUS_KEY = 'CIRCLERAD';
/** 지름 기본 = 2 × 기억 반지름 — 이 열쇠엔 쓰지 않음 */
const DIAMETER_KEY = 'CIRCLERAD.D';
const CENTER_PROMPT: PromptSpec = {
text: '원에 대한 중심점 지정',
options: [option('3P', '3P', '3점'), option('2P', '2P', '2점')],
absent: [option('TTR', 'T', 'Ttr - 접선 접선 반지름')],
};
const RADIUS_PROMPT: PromptSpec = {
text: '원의 반지름 지정',
options: [option('DIAMETER', 'D', '지름')],
default: { kind: 'value', remember: RADIUS_KEY, initial: null },
number: { positive: true },
};
const diameterPrompt = (): PromptSpec => {
const radius = recall<number>(RADIUS_KEY);
return {
text: '원의 지름 지정',
default: { kind: 'value', remember: DIAMETER_KEY, initial: radius ? radius * 2 : null },
number: { positive: true },
};
};
const ORDINAL = ['첫 번째', '두 번째', '세 번째'];
const threePrompt = (context: Partial<CircleContext>): PromptSpec => ({
text: `원 위의 ${ORDINAL[context.points?.length ?? 0]} 점 지정`,
});
const twoPrompt = (context: Partial<CircleContext>): PromptSpec => ({
text: `원 지름의 ${ORDINAL[context.points?.length ?? 0]} 끝점 지정`,
});
function addCircle(center: Point, radius: number) {
setGhostHelperEntities([]);
if (!(radius > 0)) return;
addEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))], true);
remember(RADIUS_KEY, radius);
}
/** 3P · 2P — 찍은 점들 + 마지막 점으로 원(점 모자람 · 한 줄 = null) */
function circleThrough(context: CircleContext, last: Point) {
const [a, b] = context.points;
if (!context.threePoints) {
return a
? {
center: new Point((a.x + last.x) / 2, (a.y + last.y) / 2),
radius: a.distanceTo(last)[0] / 2,
}
: null;
}
const arc = b ? arcThroughThreePoints(a, b, last) : null;
return arc ? { center: arc.center, radius: arc.radius } : null;
}
/** 점 사건 → 점(앞 점 기준 · 첫 점이면 기준 없음) */
const pointAt = (base: Point | null, event: StateEvent) =>
getPointFromEvent(base, event as PointInputEvent);
const lastOf = (points: Point[]) => points[points.length - 1] ?? null;
/** 3P 는 세 점 · 2P 는 두 점째에 원 */
const pointsEvents = (count: number, self: CircleState) => {
const enough = ({ context }: { context: CircleContext }) => context.points.length === count - 1;
const done = { guard: enough, actions: 'drawThrough', target: END_STATE };
const more = { actions: 'recordPoint', target: self };
return {
DRAW: { actions: 'previewThrough' },
MOUSE_CLICK: [done, more],
ABSOLUTE_POINT_INPUT: [done, more],
RELATIVE_POINT_INPUT: [done, more],
NUMBER_INPUT: [done, more],
ESC: { target: END_STATE },
};
};
export const circleToolStateMachine = createMachine(
{
types: {} as {
context: CircleContext;
events: StateEvent;
},
context: {
centerPoint: null,
type: Tool.CIRCLE,
},
types: {} as { context: CircleContext; events: StateEvent },
context: { centerPoint: null, points: [], threePoints: false, type: Tool.CIRCLE },
initial: CircleState.INIT,
states: {
[CircleState.INIT]: {
description: 'Initializing the circle tool',
always: {
actions: CircleAction.INIT_CIRCLE_TOOL,
target: CircleState.WAITING_FOR_CENTER_POINT,
},
always: { actions: 'init', target: CircleState.WAITING_FOR_CENTER_POINT },
},
[CircleState.WAITING_FOR_CENTER_POINT]: {
description: 'Select the center point of the circle tool',
meta: {
instructions: 'Select the center point of the circle',
},
meta: { kind: 'point', prompt: CENTER_PROMPT },
on: {
MOUSE_CLICK: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
ESC: {
actions: CircleAction.SWITCH_TO_SELECT_TOOL,
},
...firstPointEvents('recordCenter', CircleState.WAITING_FOR_RADIUS),
'OPTION.3P': { actions: 'pickThree', target: CircleState.THREE_POINTS },
'OPTION.2P': { actions: 'pickTwo', target: CircleState.TWO_POINTS },
ESC: { target: END_STATE },
},
},
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
description: 'Select a point on the circle',
meta: {
instructions: 'Select the point on the circle',
},
[CircleState.WAITING_FOR_RADIUS]: {
meta: { kind: 'point', prompt: RADIUS_PROMPT },
on: {
DRAW: {
actions: CircleAction.DRAW_TEMP_CIRCLE,
},
MOUSE_CLICK: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: CircleState.INIT,
},
NUMBER_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ESC: {
target: CircleState.INIT,
},
DRAW: { actions: 'previewRadius' },
...pointEvents('drawByRadius', END_STATE),
'OPTION.DIAMETER': { target: CircleState.WAITING_FOR_DIAMETER },
ESC: { target: END_STATE },
},
},
[CircleState.WAITING_FOR_DIAMETER]: {
meta: { kind: 'point', prompt: diameterPrompt },
on: {
DRAW: { actions: 'previewDiameter' },
...pointEvents('drawByDiameter', END_STATE),
ESC: { target: END_STATE },
},
},
[CircleState.THREE_POINTS]: {
meta: { kind: 'point', prompt: threePrompt },
on: pointsEvents(3, CircleState.THREE_POINTS),
},
[CircleState.TWO_POINTS]: {
meta: { kind: 'point', prompt: twoPrompt },
on: pointsEvents(2, CircleState.TWO_POINTS),
},
...END,
},
},
{
actions: {
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
init: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
centerPoint: null,
};
return { centerPoint: null, points: [], threePoints: false };
}),
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
centerPoint: startPoint,
};
pickThree: assign({ threePoints: true }),
pickTwo: assign({ threePoints: false }),
recordCenter: assign(({ event }) => {
const centerPoint = pointAt(null, event);
setAngleGuideOriginPoint(centerPoint);
return { centerPoint };
}),
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(
(event as DrawEvent).drawController.getWorldMouseLocation(),
context.centerPoint as Point
)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
setGhostHelperEntities([activeCircle]);
previewRadius: ({ context, event }) => {
const center = context.centerPoint as Point;
const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0];
setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]);
},
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
if (!context.centerPoint) {
throw new Error(
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool'
);
}
const pointOnCircle: Point = getPointFromEvent(
context.centerPoint,
event as PointInputEvent
);
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(pointOnCircle, context.centerPoint as Point)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
addEntities([activeCircle], true);
setGhostHelperEntities([]);
return {
centerPoint: null,
};
previewDiameter: ({ context, event }) => {
const center = context.centerPoint as Point;
const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0] / 2;
setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]);
},
drawByRadius: ({ context, event }) => {
const center = context.centerPoint as Point;
const radius =
event.type === 'NUMBER_INPUT'
? (event as NumberInputEvent).value
: center.distanceTo(pointAt(center, event))[0];
addCircle(center, radius);
},
drawByDiameter: ({ context, event }) => {
const center = context.centerPoint as Point;
const diameter =
event.type === 'NUMBER_INPUT'
? (event as NumberInputEvent).value
: center.distanceTo(pointAt(center, event))[0];
addCircle(center, diameter / 2);
},
recordPoint: assign(({ context, event }) => {
const point = pointAt(lastOf(context.points), event);
setAngleGuideOriginPoint(point);
return { points: [...context.points, point] };
}),
[CircleAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
previewThrough: ({ context, event }) => {
const last = lastOf(context.points);
if (!last) return;
const circle = circleThrough(context, getPointFromEvent(last, event as DrawEvent));
setGhostHelperEntities(
circle ? [styled(new CircleEntity(getActiveLayerId(), circle.center, circle.radius))] : []
);
},
drawThrough: ({ context, event }) => {
const circle = circleThrough(context, pointAt(lastOf(context.points), event));
if (circle) addCircle(circle.center, circle.radius);
else setGhostHelperEntities([]);
},
},
}
+299 -259
View File
@@ -1,281 +1,321 @@
import type { Point } from '@flatten-js/core';
/**
* COPY(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:」 →
* 「두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:」 → 첫 사본 뒤
* 「두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:」 되풀이(빈 Enter · Esc = 끝 · 만든 사본 남음).
* O → 「복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:」(COPYMODE 기억) · M = 이번만 되풀이(단일 때만 보임) ·
* A → 「배열할 항목 수 입력:」 → 「두 번째 점 지정 또는 [맞춤(F)]:」 · U = 마지막 사본 되돌림.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../commands/prompt';
import type { Entity } from '../entities/Entity';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import {
addEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
undo,
} from '../state';
import { Tool } from '../tools';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { LineEntity } from '../entities/LineEntity';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
export interface CopyContext extends ToolContext {
startPoint: Point | null;
originalSelectedEntities: Entity[];
copiedEntities: Entity[];
lastDrawLocation: Point | null;
}
import { cloneKeepingLook } from './move-tool.helpers';
import {
type DrawEvent,
END_STATE,
type NumberInputEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
import {
type PickedContext,
endState,
guideLine,
selectionState,
startState,
} from './transform.helpers';
export enum CopyState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT',
WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT',
WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT',
WAITING_FOR_COPY_MODE = 'WAITING_FOR_COPY_MODE',
WAITING_FOR_SECOND_POINT = 'WAITING_FOR_SECOND_POINT',
WAITING_FOR_ARRAY_COUNT = 'WAITING_FOR_ARRAY_COUNT',
WAITING_FOR_ARRAY_POINT = 'WAITING_FOR_ARRAY_POINT',
WAITING_FOR_DISPLACEMENT = 'WAITING_FOR_DISPLACEMENT',
}
export enum CopyAction {
INIT_COPY_TOOL = 'INIT_COPY_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_START_POINT = 'RECORD_START_POINT',
COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY',
DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES',
COPY_SELECTION = 'COPY_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
export interface CopyContext extends ToolContext, PickedContext {
base: Point | null;
/** COPYMODE 단일 */
single: boolean;
/** 다중(M) — 이번만 되풀이 */
multiple: boolean;
/** 만든 사본 묶음 수(U 되돌림 · 물음 바뀜) */
made: number;
/** 배열(A) 항목 수(원본 포함) */
items: number;
/** 맞춤(F) — 두 번째 점 = 마지막 항목 */
fit: boolean;
}
/**
* Copy tool state machine
* This state machine is responsible for copying entities around the canvas
* It uses the select tool state machine to select entities to copy
* When the user presses enter, the selected entities are marked for copying
* The user can then select a start point
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
* When the user clicks again, the end point is selected and the entities are copied to the new location
*/
export const copyToolStateMachine = createMachine(
const MODE: OptionSpec = { key: 'MODE', kw: 'O', en: 'MODE', label: '모드' };
const SINGLE: OptionSpec = { key: 'SINGLE', kw: 'S', en: 'SINGLE', label: '단일' };
const ARRAY: OptionSpec = { key: 'ARRAY', kw: 'A', en: 'ARRAY', label: '배열' };
const FIT: OptionSpec = { key: 'FIT', kw: 'F', en: 'FIT', label: '맞춤' };
/** 한 번만 만들고 끝 — 단일 모드이고 다중(M) 안 고름 */
const once = (context: Partial<CopyContext>) => !!context.single && !context.multiple;
const PROMPT = {
base: (context: Partial<CopyContext>): PromptSpec => ({
text: '기준점 지정',
options: [OPT.DISPLACEMENT, MODE, ...(once(context) ? [OPT.MULTIPLE] : [])],
default: { kind: 'option', key: OPT.DISPLACEMENT.key },
}),
mode: (context: Partial<CopyContext>): PromptSpec => ({
text: '복사 모드 옵션 입력',
options: [SINGLE, OPT.MULTIPLE],
default: { kind: 'option', key: context.single ? SINGLE.key : OPT.MULTIPLE.key },
}),
second: (context: Partial<CopyContext>): PromptSpec =>
context.made
? {
text: '두 번째 점 지정',
options: [ARRAY, OPT.EXIT, OPT.UNDO],
default: { kind: 'enter', label: '종료' },
}
: {
text: '두 번째 점 지정',
options: [ARRAY],
default: { kind: 'enter', label: '첫 번째 점을 변위로 사용' },
},
count: {
text: '배열할 항목 수 입력',
number: { int: true, min: 2, max: 32767 },
} satisfies PromptSpec,
arrayPoint: (context: Partial<CopyContext>): PromptSpec => ({
text: '두 번째 점 지정',
...(context.fit ? {} : { options: [FIT] }),
}),
displacement: {
text: '변위 지정',
default: { kind: 'value', remember: 'COPY.DISP', initial: { x: 0, y: 0 } },
} satisfies PromptSpec,
};
type Ctx = { context: CopyContext; event: StateEvent };
/** 변위 (dx, dy) 를 1 · 2 · … 배 한 사본들 — 배열(A)이면 항목 수 − 1 벌 */
function copiesAt(context: CopyContext, dx: number, dy: number, sets: number): Entity[] {
const copies: Entity[] = [];
for (let index = 1; index <= sets; index++) {
for (const entity of context.originals) {
const copy = cloneKeepingLook(entity);
copy.move(dx * index, dy * index);
copies.push(copy);
}
}
return copies;
}
/** 배열(A) — 맞춤이면 두 번째 점이 마지막 항목 */
function arrayCopies(context: CopyContext, dx: number, dy: number): Entity[] {
const sets = context.items - 1;
const scale = context.fit ? 1 / sets : 1;
return copiesAt(context, dx * scale, dy * scale, sets);
}
const vectorTo = ({ context, event }: Ctx) => {
const base = context.base as Point;
const dest = getPointFromEvent(base, event as PointInputEvent);
return new Point(dest.x - base.x, dest.y - base.y);
};
const place = (copies: Entity[]) => {
if (copies.length) addEntities(copies, true);
setGhostHelperEntities([]);
};
const bump = assign(({ context }: Ctx) => ({ made: context.made + 1, fit: false }));
const onPoints = (handler: unknown) => ({
MOUSE_CLICK: handler,
ABSOLUTE_POINT_INPUT: handler,
RELATIVE_POINT_INPUT: handler,
NUMBER_INPUT: handler,
});
const esc = { ESC: { target: END_STATE } };
/** 사본 놓음 → 단일이면 끝 · 아니면 다시 두 번째 점 */
const placeThen = (copies: (args: Ctx) => Entity[]) => [
{
types: {} as {
context: CopyContext;
events: StateEvent;
},
context: {
startPoint: null,
originalSelectedEntities: [],
copiedEntities: [],
lastDrawLocation: null,
type: Tool.COPY,
},
initial: CopyState.INIT,
states: {
[CopyState.INIT]: {
description: 'Initializing the copy tool',
always: {
actions: CopyAction.INIT_COPY_TOOL,
target: CopyState.CHECK_SELECTION,
},
},
[CopyState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: CopyState.WAITING_FOR_START_COPY_POINT,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: CopyState.WAITING_FOR_SELECTION,
},
],
},
[CopyState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {
startPoint: null,
};
}),
target: CopyState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
},
},
[CopyState.WAITING_FOR_START_COPY_POINT]: {
description: 'Select the start of the copy line',
meta: {
instructions: 'Select the start of the copy line',
},
always: {
actions: CopyAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
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,
},
},
},
[CopyState.WAITING_FOR_END_COPY_POINT]: {
description: 'Select the end of the copy line',
meta: {
instructions: 'Select the end of the copy line',
},
on: {
DRAW: {
actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES],
},
MOUSE_CLICK: {
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
actions: CopyAction.DESELECT_ENTITIES,
target: CopyState.INIT,
},
},
},
},
guard: ({ context }: Ctx) => once(context),
actions: (args: Ctx) => place(copies(args)),
target: END_STATE,
},
{
actions: {
[CopyAction.INIT_COPY_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
originalSelectedEntities: [],
copiedEntities: [],
lastDrawLocation: null,
};
}),
[CopyAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
startPoint: (event as MouseClickEvent).worldMouseLocation,
};
}),
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
actions: [(args: Ctx) => place(copies(args)), bump],
target: CopyState.WAITING_FOR_SECOND_POINT,
},
];
// Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
const recordBase = {
actions: assign(({ event }: Ctx) => {
const base = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(base);
return { base };
}),
target: CopyState.WAITING_FOR_SECOND_POINT,
};
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides
const S = CopyState;
setSelectedEntityIds([]);
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))),
copiedEntities: selectedEntities,
};
}),
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[COPY] Calling draw temp copy line without a start point');
}
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// 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));
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y
);
// // Draw a dashed line between the start copy point and the current mouse location
const activeCopyLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp
);
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
activeCopyLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeCopyLine, ...movedEntities]);
},
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[COPY] Calling copy selection without a start point');
}
// Copy the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
moveEntities(
copiedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y
);
// 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임).
addEntities(copiedEntities, true);
},
[CopyAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
[CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
...selectToolStateMachine.implementations.actions,
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
const states: Record<string, any> = {
[S.INIT]: startState(S.WAITING_FOR_BASE_POINT),
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_BASE_POINT),
[S.WAITING_FOR_BASE_POINT]: {
meta: { kind: 'point', prompt: PROMPT.base },
entry: () => setShouldDrawHelpers(true),
on: {
MOUSE_CLICK: recordBase,
ABSOLUTE_POINT_INPUT: recordBase,
'OPTION.DISPLACEMENT': { target: S.WAITING_FOR_DISPLACEMENT },
'OPTION.MODE': { target: S.WAITING_FOR_COPY_MODE },
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
...esc,
},
}
);
},
[S.WAITING_FOR_COPY_MODE]: {
meta: { kind: 'keyword', prompt: PROMPT.mode },
on: {
'OPTION.SINGLE': {
actions: [assign({ single: true }), () => remember('COPYMODE', SINGLE.key)],
target: S.WAITING_FOR_BASE_POINT,
},
'OPTION.MULTIPLE': {
actions: [assign({ single: false }), () => remember('COPYMODE', OPT.MULTIPLE.key)],
target: S.WAITING_FOR_BASE_POINT,
},
...esc,
},
},
[S.WAITING_FOR_SECOND_POINT]: {
meta: { kind: 'point', prompt: PROMPT.second },
on: {
DRAW: {
actions: ({ context, event }: Ctx) => {
const base = context.base as Point;
const dest = getPointFromEvent(base, event as DrawEvent);
setGhostHelperEntities([
guideLine(base, dest),
...copiesAt(context, dest.x - base.x, dest.y - base.y, 1),
]);
},
},
...onPoints(
placeThen((args) => {
const vector = vectorTo(args);
return copiesAt(args.context, vector.x, vector.y, 1);
})
),
// 첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · 그 뒤 = <종료>
ENTER: [
{
guard: ({ context }: Ctx) => context.made === 0,
actions: ({ context }: Ctx) => {
const base = context.base as Point;
place(copiesAt(context, base.x, base.y, 1));
},
target: END_STATE,
},
{ target: END_STATE },
],
'OPTION.ARRAY': { target: S.WAITING_FOR_ARRAY_COUNT },
'OPTION.EXIT': { target: END_STATE },
'OPTION.UNDO': {
guard: ({ context }: Ctx) => context.made > 0,
actions: [
() => {
undo();
setGhostHelperEntities([]);
},
assign(({ context }: Ctx) => ({ made: context.made - 1 })),
],
},
...esc,
},
},
[S.WAITING_FOR_ARRAY_COUNT]: {
meta: { kind: 'number', prompt: PROMPT.count },
entry: () => setGhostHelperEntities([]),
on: {
NUMBER_INPUT: {
actions: assign(({ event }: Ctx) => ({ items: (event as NumberInputEvent).value })),
target: S.WAITING_FOR_ARRAY_POINT,
},
...esc,
},
},
[S.WAITING_FOR_ARRAY_POINT]: {
meta: { kind: 'point', prompt: PROMPT.arrayPoint },
on: {
DRAW: {
actions: ({ context, event }: Ctx) => {
const base = context.base as Point;
const dest = getPointFromEvent(base, event as DrawEvent);
setGhostHelperEntities([
guideLine(base, dest),
...arrayCopies(context, dest.x - base.x, dest.y - base.y),
]);
},
},
...onPoints(
placeThen((args) => {
const vector = vectorTo(args);
return arrayCopies(args.context, vector.x, vector.y);
})
),
'OPTION.FIT': { actions: assign({ fit: true }) },
...esc,
},
},
[S.WAITING_FOR_DISPLACEMENT]: {
meta: { kind: 'point', prompt: PROMPT.displacement },
entry: () => setAngleGuideOriginPoint(null),
on: {
...Object.fromEntries(
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
type,
{
actions: ({ context, event }: Ctx) => {
const vector = getPointFromEvent(null, event as PointInputEvent);
remember('COPY.DISP', { x: vector.x, y: vector.y });
place(copiesAt(context, vector.x, vector.y, 1));
},
target: END_STATE,
},
])
),
...esc,
},
},
[END_STATE]: endState,
};
export const copyToolStateMachine = createMachine({
types: {} as { context: CopyContext; events: StateEvent },
context: () => ({
originals: [],
base: null,
single: recall('COPYMODE') === SINGLE.key,
multiple: false,
made: 0,
items: 2,
fit: false,
type: Tool.COPY,
}),
initial: S.INIT,
states,
});
@@ -0,0 +1,126 @@
/**
* 호(ARC) — 337 설계 1-1 · 오토캐드 차례(순서 엔진 v2).
* 「호의 시작점 지정:」 → 「호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:」 → 「호의 끝점 지정:」(세 점) ·
* C → 「호의 중심점 지정:」 → 「호의 끝점 지정 또는 [각도(A)]:」(A → 「사이각 지정:」) ·
* E → 「호의 끝점 지정:」 → 「호의 중심점 지정 또는 [반지름(R)]:」(R → 「호의 반지름 지정:」) ·
* 중심 · 끝 · 반지름 방식은 시작점에서 반시계(오토캐드) · 사이각 − = 시계 · 반지름 = 작은 호. 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import type { Entity } from '../../entities/Entity';
import { type ArcDefinition, arcThroughThreePoints } from '../../helpers/geometry/shape-points';
import { addEntities } from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity } from '../factories/entity-factory';
import { type SequenceInput, createSequenceTool } from '../factories/sequence-tool';
import { option, rememberDrawEnd } from './draw-steps';
const angleOf = (center: Point, point: Point) => Math.atan2(point.y - center.y, point.x - center.x);
/** 중심 · 시작점 · 끝각 — 반시계(사이각 − 면 시계) */
const arcAround = (center: Point, start: Point, endAngle: number, ccw = true): ArcDefinition => ({
center,
radius: center.distanceTo(start)[0],
startAngle: angleOf(center, start),
endAngle,
counterClockwise: ccw,
});
/** 시작 · 끝 · 반지름 — 반시계 작은 호 · 반지름이 현의 반보다 작으면 null */
function arcByRadius(start: Point, end: Point, radius: number): ArcDefinition | null {
const half = start.distanceTo(end)[0] / 2;
if (half === 0 || radius < half) return null;
const rise = Math.sqrt(radius * radius - half * half);
const ux = (end.x - start.x) / (2 * half);
const uy = (end.y - start.y) / (2 * half);
const center = new Point((start.x + end.x) / 2 - uy * rise, (start.y + end.y) / 2 + ux * rise);
return arcAround(center, start, angleOf(center, end));
}
/** 지금 값으로 호 — 커서(미리보기)가 마지막 빈 값을 채움 */
function arcOf(input: SequenceInput, cursor?: Point): ArcDefinition | null {
const start = input.get<Point>('start');
const at = (id: string) => (input.has(id) ? input.get<Point>(id) : cursor);
if (input.mode === 'C' || input.mode === 'CA') {
const center = input.get<Point>('center');
if (input.mode === 'CA') {
const sweep = input.has('angle') ? input.get<number>('angle') : null;
if (sweep === null) return null;
return arcAround(center, start, angleOf(center, start) + (sweep * Math.PI) / 180, sweep > 0);
}
const end = at('cEnd');
return end ? arcAround(center, start, angleOf(center, end)) : null;
}
if (input.mode === 'E' || input.mode === 'ER') {
const end = input.get<Point>('eEnd');
if (input.mode === 'ER') {
const radius = at('radius');
return radius ? arcByRadius(start, end, end.distanceTo(radius)[0]) : null;
}
const center = at('eCenter');
return center ? arcAround(center, start, angleOf(center, end)) : null;
}
const third = at('third');
return third ? arcThroughThreePoints(start, input.get<Point>('second'), third) : null;
}
const POINT_TEXT = '호의 끝점 지정';
const when =
(...modes: (string | null)[]) =>
(input: SequenceInput) =>
modes.includes(input.mode);
export const arcToolStateMachine = createSequenceTool({
tool: Tool.ARC,
steps: [
{ kind: 'point', id: 'start', prompt: { text: '호의 시작점 지정' } },
{
kind: 'point',
id: 'second',
prompt: {
text: '호의 두 번째 점 지정',
options: [option('CENTER', 'C', '중심'), option('END', 'E', '끝')],
},
options: { CENTER: { mode: 'C', next: 'center' }, END: { mode: 'E', next: 'eEnd' } },
},
{ kind: 'point', id: 'third', prompt: { text: POINT_TEXT }, when: when(null) },
{ kind: 'point', id: 'center', prompt: { text: '호의 중심점 지정' }, when: when('C', 'CA') },
{
kind: 'point',
id: 'cEnd',
prompt: { text: POINT_TEXT, options: [option('ANGLE', 'A', '각도')] },
options: { ANGLE: { mode: 'CA', next: 'angle' } },
when: when('C'),
},
{ kind: 'number', id: 'angle', prompt: { text: '사이각 지정' }, when: when('CA') },
{ kind: 'point', id: 'eEnd', prompt: { text: POINT_TEXT }, when: when('E', 'ER') },
{
kind: 'point',
id: 'eCenter',
prompt: { text: '호의 중심점 지정', options: [option('RADIUS', 'R', '반지름')] },
options: { RADIUS: { mode: 'ER', next: 'radius' } },
when: when('E'),
},
{ kind: 'point', id: 'radius', prompt: { text: '호의 반지름 지정' }, when: when('ER') },
],
preview: (input) => {
const arc = input.values.length > 1 ? arcOf(input, input.cursor) : null;
const shown: Entity[] = arc ? [arcEntity(arc)] : [];
const last = input.points()[input.points().length - 1];
return arc || !last ? shown : [lineEntity(last, input.cursor)];
},
commit: (input) => {
const arc = arcOf(input);
if (!arc) {
// 세 점 한 줄 = 선(오토캐드는 거절 — 버리지 않게 선으로)
if (input.mode === null) {
addEntities([lineEntity(input.get<Point>('start'), input.get<Point>('third'))], true);
}
return;
}
addEntities([arcEntity(arc)], true);
rememberDrawEnd({
x: arc.center.x + arc.radius * Math.cos(arc.endAngle),
y: arc.center.y + arc.radius * Math.sin(arc.endAngle),
});
},
});
@@ -1,114 +1,65 @@
/** 그리기 명령 — 폴리선·호·다각형·타원·스플라인·점 (조사표 1절) */
/** 그리기 명령 — 스플라인·점·도넛 + 폴리선·호·다각형·타원 다시 내보냄 (조사표 1절 · 337 설계 1-1) */
import { CircleEntity } from '../../entities/CircleEntity';
import type { Entity } from '../../entities/Entity';
import {
arcThroughThreePoints,
ellipsePoints,
regularPolygonPoints,
splinePoints,
} from '../../helpers/geometry/shape-points';
import type { Point } from '@flatten-js/core';
import { OPT } from '../../commands/prompt';
import { splinePoints } from '../../helpers/geometry/shape-points';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import {
arcEntity,
lineEntity,
pointEntity,
polyLineEntity,
styled,
} from '../factories/entity-factory';
import { pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { option } from './draw-steps';
export const plineToolStateMachine = createSequenceTool({
tool: Tool.PLINE,
steps: [
{ kind: 'point', instructions: '폴리선의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
],
repeatLastStep: true,
preview: (input) => {
const preview = polyLineEntity([...input.points(), input.cursor]);
return preview ? [preview] : [];
},
commit: (input) => {
const polyline = polyLineEntity(input.points());
if (polyline) addEntities([polyline], true);
},
});
export { arcToolStateMachine } from './arc-tool';
export { ellipseToolStateMachine } from './ellipse-tool';
export { plineToolStateMachine } from './pline-tool';
export { polygonToolStateMachine } from './polygon-tool';
export const arcToolStateMachine = createSequenceTool({
tool: Tool.ARC,
steps: [
{ kind: 'point', instructions: '호의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '호가 지나갈 두 번째 점을 지정하십시오.' },
{ kind: 'point', instructions: '호의 끝점을 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
if (points.length === 2) {
const definition = arcThroughThreePoints(points[0], points[1], input.cursor);
return definition ? [arcEntity(definition)] : [lineEntity(points[0], input.cursor)];
}
return [];
},
commit: (input) => {
const [start, through, end] = input.points();
const definition = arcThroughThreePoints(start, through, end);
// 세 점이 일직선이면 호가 성립하지 않으므로 선으로 대체한다
addEntities([definition ? arcEntity(definition) : lineEntity(start, end)], true);
},
});
/** 닫힌 스플라인 — 끝 둘레 이웃을 이어 붙여 그린 뒤 이음 구간만 남김 */
function closedSplinePoints(points: Point[]): Point[] {
const spans = 12;
const wrapped = splinePoints([points[points.length - 1], ...points, points[0], points[1]], spans);
const loop = wrapped.slice(spans, spans * (points.length + 1));
return [...loop, loop[0]];
}
export const polygonToolStateMachine = createSequenceTool({
tool: Tool.POLYGON,
steps: [
{ kind: 'number', instructions: '면의 수를 입력하십시오 <6>.', defaultValue: 6 },
{ kind: 'point', instructions: '다각형의 중심을 지정하십시오.' },
{ kind: 'point', instructions: '꼭짓점을 지정하십시오 (중심에서의 반지름).' },
],
preview: (input) => {
const points = input.points();
if (points.length !== 1) return [];
const polygon = polyLineEntity(regularPolygonPoints(points[0], input.cursor, input.number(0)));
return polygon ? [polygon] : [];
},
commit: (input) => {
const [center, vertex] = input.points();
const polygon = polyLineEntity(regularPolygonPoints(center, vertex, input.number(0)));
if (polygon) addEntities([polygon], true);
},
});
export const ellipseToolStateMachine = createSequenceTool({
tool: Tool.ELLIPSE,
steps: [
{ kind: 'point', instructions: '타원의 중심을 지정하십시오.' },
{ kind: 'point', instructions: '장축의 끝점을 지정하십시오.' },
{ kind: 'point', instructions: '단축 거리를 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
if (points.length === 2) {
const minor = points[0].distanceTo(input.cursor)[0];
const ellipse = polyLineEntity(ellipsePoints(points[0], points[1], minor));
return ellipse ? [ellipse] : [];
}
return [];
},
commit: (input) => {
const [center, majorPoint, minorPoint] = input.points();
const minorRadius = center.distanceTo(minorPoint)[0];
const ellipse = polyLineEntity(ellipsePoints(center, majorPoint, minorRadius));
if (ellipse) addEntities([ellipse], true);
},
});
const NEXT_POINT = '다음 점 입력';
const TOLERANCE = option('TOLERANCE', 'L', '공차');
/**
* 스플라인(SPLINE) — 337 설계 1-1. 「첫 번째 점 지정:」 → 「다음 점 입력:」 → 「다음 점 입력 또는 [명령 취소(U)]:」 →
* 넷째 점부터 [명령 취소(U)/닫기(C)] 되풀이 · 빈 Enter = 확정 · C = 닫고 끝 · Esc = 버림.
*/
export const splineToolStateMachine = createSequenceTool({
tool: Tool.SPLINE,
steps: [
{ kind: 'point', instructions: '스플라인의 첫 점을 지정하십시오.' },
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
{
kind: 'point',
prompt: {
text: '첫 번째 점 지정',
absent: [
option('METHOD', 'M', '방법'),
option('KNOTS', 'K', '매듭'),
option('OBJECT', 'O', '객체'),
],
},
},
{
kind: 'point',
prompt: {
text: NEXT_POINT,
absent: [option('TANGENT', 'T', '시작 접촉부'), TOLERANCE],
},
},
{
kind: 'point',
prompt: (input) => ({
text: NEXT_POINT,
options: input.points().length >= 3 ? [OPT.UNDO, OPT.CLOSE] : [OPT.UNDO],
absent: [option('TANGENT', 'T', '끝 접촉부'), TOLERANCE],
}),
options: { UNDO: 'undo', CLOSE: 'close' },
},
],
repeatLastStep: true,
preview: (input) => {
@@ -116,7 +67,8 @@ export const splineToolStateMachine = createSequenceTool({
return preview ? [preview] : [];
},
commit: (input) => {
const spline = polyLineEntity(splinePoints(input.points()));
const points = input.points();
const spline = polyLineEntity(input.closed ? closedSplinePoints(points) : splinePoints(points));
if (spline) addEntities([spline], true);
},
});
@@ -138,6 +90,7 @@ export const donutToolStateMachine = createSequenceTool({
{ kind: 'number', instructions: '도넛의 외부 지름을 입력하십시오 <2>.', defaultValue: 2 },
{ kind: 'point', instructions: '도넛의 중심을 지정하십시오.' },
],
loopFrom: 2, // 오토캐드 — 중심 되풀이 · 빈 Enter 끝
preview: (input) => donutEntities(input.number(0), input.number(1), input.cursor),
commit: (input) => {
addEntities(donutEntities(input.number(0), input.number(1), input.point(2)), true);
@@ -3,11 +3,12 @@ import { Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import type { Entity } from '../../entities/Entity';
import { getMlineElements, getMlineSpacing, setMlineStyle } from '../../commands/draw-settings';
import type { OptionSpec, PromptSpec } from '../../commands/prompt';
import { offsetPolylinePoints } from '../../helpers/geometry/shape-points';
import { addEntities, getScreenCanvasDrawController } from '../../state';
import { Tool } from '../../tools';
import { hatchEntity, lineEntity, polyLineEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
/**
* 구성선은 원래 무한하지만, 무한 선은 경계상자를 망가뜨려 [범위 줌]을 못 쓰게 만든다.
@@ -32,30 +33,75 @@ function extendFrom(base: Point, through: Point, bothWays: boolean): [Point, Poi
return [backward, forward];
}
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
/** 통과점 물음 — 빈 Enter = 끝(기본은 안 그림) */
const THROUGH: PromptSpec = { text: '통과점을 지정', default: { kind: 'option', key: 'DONE' } };
/** 구성선 양 끝 — 수평 · 수직 · 각도 모드는 통과점 하나로 · 아니면 첫 점 → 통과점 방향 */
function xlineEnds(input: SequenceInput, through: Point): [Point, Point] | null {
const degrees = { H: 0, V: 90, A: input.has('angle') ? input.get<number>('angle') : 0 }[
input.mode ?? ''
];
if (degrees === undefined) {
const base = input.get<Point>('base');
if (!base || (base.x === through.x && base.y === through.y)) return null;
return extendFrom(base, through, true);
}
const radians = (degrees * Math.PI) / 180;
const toward = new Point(through.x + Math.cos(radians), through.y + Math.sin(radians));
return extendFrom(through, toward, true);
}
/** XLINE — 오토캐드 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 */
export const xlineToolStateMachine = createSequenceTool({
tool: Tool.XLINE,
loopFrom: 'through',
steps: [
{ kind: 'point', instructions: '구성선이 지날 점을 지정하십시오.' },
{ kind: 'point', instructions: '방향을 지정하십시오.' },
{
kind: 'point',
id: 'base',
prompt: {
text: '점 지정',
options: [opt('HOR', 'H', '수평'), opt('VER', 'V', '수직'), opt('ANG', 'A', '각도')],
absent: [opt('BISECT', 'B', '이등분'), opt('OFFSET', 'O', '간격띄우기')],
},
options: {
HOR: { mode: 'H', next: 'through' },
VER: { mode: 'V', next: 'through' },
ANG: { mode: 'A', next: 'angle' },
},
},
{
kind: 'number',
id: 'angle',
when: (input) => input.mode === 'A',
prompt: {
text: 'X선의 각도 입력',
default: { kind: 'value', remember: 'XLINE.ANGLE', initial: 0, format: 'ang' },
absent: [opt('REFERENCE', 'R', '참조')],
},
},
{ kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } },
],
preview: (input) => {
const points = input.points();
if (points.length !== 1) return [];
const [start, end] = extendFrom(points[0], input.cursor, true);
return [lineEntity(start, end)];
if (!input.mode && !input.has('base')) return [];
const ends = xlineEnds(input, input.cursor);
return ends ? [lineEntity(...ends)] : [];
},
commit: (input) => {
const [base, through] = input.points();
const [start, end] = extendFrom(base, through, true);
addEntities([lineEntity(start, end)], true);
const ends = xlineEnds(input, input.get<Point>('through'));
if (ends) addEntities([lineEntity(...ends)], true);
},
});
/** RAY — 「시작점을 지정:」 → 「통과점을 지정:」 되풀이 */
export const rayToolStateMachine = createSequenceTool({
tool: Tool.RAY,
loopFrom: 'through',
steps: [
{ kind: 'point', instructions: '광선의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '통과점을 지정하십시오.' },
{ kind: 'point', id: 'base', prompt: { text: '시작점을 지정' } },
{ kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } },
],
preview: (input) => {
const points = input.points();
@@ -1,5 +1,9 @@
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
/**
* 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다.
* 오토캐드 물음 차례(337 설계 1-2) · 기본값 없음 · 한 번 뒤 끝 · 블록(B) 없음.
*/
import { toast } from 'react-toastify';
import type { OptionSpec } from '../../commands/prompt';
import {
dividePoints,
measurePoints,
@@ -10,12 +14,21 @@ import { Tool } from '../../tools';
import { pointEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
const BLOCK: OptionSpec = { key: 'BLOCK', kw: 'B', en: 'BLOCK', label: '블록' };
export const divideToolStateMachine = createSequenceTool({
tool: Tool.DIVIDE,
helpers: false,
steps: [
{ kind: 'entity', instructions: '등분할 객체를 선택하십시오.' },
{ kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 },
{ kind: 'entity', prompt: { text: '등분할 객체 선택' } },
{
kind: 'number',
prompt: {
text: '세그먼트 수 입력',
absent: [BLOCK],
number: { int: true, min: 2, max: 32767 },
},
},
],
commit: (input) => {
const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1));
@@ -31,11 +44,19 @@ export const measureLengthToolStateMachine = createSequenceTool({
tool: Tool.MEASURE_LENGTH,
helpers: false,
steps: [
{ kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' },
{ kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 },
{ kind: 'entity', prompt: { text: '길이 분할 객체 선택' } },
{
kind: 'number',
prompt: { text: '세그먼트의 길이 지정', absent: [BLOCK], number: { positive: true } },
},
],
commit: (input) => {
const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1));
// 누른 자리에 가까운 끝부터 잼(오토캐드)
const path = sampleEntityPoints(input.entity(0));
const pick = input.pick(0);
const nearEnd =
path.length > 1 && path[path.length - 1].distanceTo(pick)[0] < path[0].distanceTo(pick)[0];
const points = measurePoints(nearEnd ? [...path].reverse() : path, input.number(1));
if (!points.length) {
toast.warn('지정한 길이로 나눌 수 없습니다.');
return;
@@ -0,0 +1,39 @@
/**
* 손으로 짠 그리기 도구 공용(337 설계 2-14) — 점 물음 사건 · 옵션 한 줄 · 끝 상태 · 마지막 그린 끝점(LINE.END).
* 선 · 폴리선 · 원 · 사각형 · 다각형 · 타원이 같이 씀.
*/
import { Point } from '@flatten-js/core';
import { type OptionSpec, type XY, recall, remember } from '../../commands/prompt';
import { END_STATE } from '../tool.types';
/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.<key>` · 맞추기 kw · en */
export const option = (key: string, kw: string, label: string, en = key): OptionSpec => ({
key,
kw,
en,
label,
});
/** 첫 점 물음(기준점 없음) — 누름 · 절대 좌표만 */
export const firstPointEvents = (actions: string | string[], target: string) => ({
MOUSE_CLICK: { actions, target },
ABSOLUTE_POINT_INPUT: { actions, target },
});
/** 기준점 있는 점 물음 — 누름 · 절대 · 상대 · 거리 숫자 */
export const pointEvents = (actions: string | string[], target: string) => ({
...firstPointEvents(actions, target),
RELATIVE_POINT_INPUT: { actions, target },
NUMBER_INPUT: { actions, target },
});
/** 명령 끝 상태 — 들면 쉬는 상태(선택 도구)로 */
export const END = { [END_STATE]: { type: 'final' as const } };
// 마지막 선 · 폴리선 · 호 끝점 — LINE · PLINE 첫 점 빈 Enter 가 여기서 이어 그림(오토캐드)
const LINE_END = 'LINE.END';
export const rememberDrawEnd = (point: XY): void => remember(LINE_END, { x: point.x, y: point.y });
export const lastDrawEnd = (): Point | null => {
const point = recall<XY>(LINE_END);
return point ? new Point(point.x, point.y) : null;
};
@@ -0,0 +1,176 @@
/**
* 타원(ELLIPSE) — 337 설계 1-1 · 오토캐드 차례.
* 「타원의 축 끝점 지정 또는 [중심(C)]:」 → 「축의 다른 끝점 지정:」 → 「다른 축으로 거리를 지정 또는 [회전(R)]:」 ·
* C → 「타원의 중심 지정:」 → 「축의 끝점 지정:」 → 같은 거리 물음 · R → 「주축 둘레로 회전 지정:」(다른 축 = 축 × cos).
* 다른 축 거리 = 중심에서 잰 반 길이(숫자도 · N10). 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { getPointFromEvent } from '../../helpers/get-point-from-event';
import { ellipsePoints } from '../../helpers/geometry/shape-points';
import {
addEntities,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../../state';
import { Tool } from '../../tools';
import { lineEntity, polyLineEntity } from '../factories/entity-factory';
import {
type DrawEvent,
END_STATE,
type NumberInputEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from '../tool.types';
import { END, firstPointEvents, option, pointEvents } from './draw-steps';
export interface EllipseContext extends ToolContext {
/** 축 끝점 방식의 첫 끝 · 중심 방식의 중심 */
first: Point | null;
center: Point | null;
axisEnd: Point | null;
}
enum S {
INIT = 'INIT',
AXIS_START = 'AXIS_START',
AXIS_END = 'AXIS_END',
CENTER = 'CENTER',
CENTER_AXIS = 'CENTER_AXIS',
DISTANCE = 'DISTANCE',
ROTATION = 'ROTATION',
}
const ellipseOf = (context: EllipseContext, other: number) =>
polyLineEntity(ellipsePoints(context.center as Point, context.axisEnd as Point, other));
const add = (context: EllipseContext, other: number) => {
const ellipse = ellipseOf(context, other);
setGhostHelperEntities([]);
if (ellipse) addEntities([ellipse], true);
};
/** 축이 정해짐 — 다른 축 거리의 고무줄 · 거리 숫자 기준 = 중심 */
const axisDone = (center: Point, axisEnd: Point) => {
setAngleGuideOriginPoint(center);
return { center, axisEnd };
};
export const ellipseToolStateMachine = createMachine(
{
types: {} as { context: EllipseContext; events: StateEvent },
context: { first: null, center: null, axisEnd: null, type: Tool.ELLIPSE },
initial: S.INIT,
states: {
[S.INIT]: { always: { actions: 'init', target: S.AXIS_START } },
[S.AXIS_START]: {
meta: {
kind: 'point',
prompt: {
text: '타원의 축 끝점 지정',
options: [option('CENTER', 'C', '중심')],
absent: [option('ARC', 'A', '호')],
},
},
on: {
...firstPointEvents('recordFirst', S.AXIS_END),
'OPTION.CENTER': { target: S.CENTER },
ESC: { target: END_STATE },
},
},
[S.AXIS_END]: {
meta: { kind: 'point', prompt: { text: '축의 다른 끝점 지정' } },
on: {
DRAW: { actions: 'previewAxis' },
...pointEvents('recordAxisEnd', S.DISTANCE),
ESC: { target: END_STATE },
},
},
[S.CENTER]: {
meta: { kind: 'point', prompt: { text: '타원의 중심 지정' } },
on: { ...firstPointEvents('recordFirst', S.CENTER_AXIS), ESC: { target: END_STATE } },
},
[S.CENTER_AXIS]: {
meta: { kind: 'point', prompt: { text: '축의 끝점 지정' } },
on: {
DRAW: { actions: 'previewAxis' },
...pointEvents('recordCenterAxis', S.DISTANCE),
ESC: { target: END_STATE },
},
},
[S.DISTANCE]: {
meta: {
kind: 'point',
prompt: {
text: '다른 축으로 거리를 지정',
options: [option('ROTATION', 'R', '회전')],
number: { positive: true },
},
},
on: {
DRAW: { actions: 'previewDistance' },
...pointEvents('drawByDistance', END_STATE),
'OPTION.ROTATION': { target: S.ROTATION },
ESC: { target: END_STATE },
},
},
[S.ROTATION]: {
meta: {
kind: 'number',
prompt: { text: '주축 둘레로 회전 지정', number: { min: 0, max: 89.4 } },
},
on: {
NUMBER_INPUT: { actions: 'drawByRotation', target: END_STATE },
ESC: { target: END_STATE },
},
},
...END,
},
},
{
actions: {
init: () => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
},
recordFirst: assign(({ event }) => {
const first = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(first);
return { first };
}),
previewAxis: ({ context, event }) => {
const first = context.first as Point;
setGhostHelperEntities([lineEntity(first, getPointFromEvent(first, event as DrawEvent))]);
},
recordAxisEnd: assign(({ context, event }) => {
const first = context.first as Point;
const end = getPointFromEvent(first, event as PointInputEvent);
return axisDone(new Point((first.x + end.x) / 2, (first.y + end.y) / 2), end);
}),
recordCenterAxis: assign(({ context, event }) => {
const center = context.first as Point;
return axisDone(center, getPointFromEvent(center, event as PointInputEvent));
}),
previewDistance: ({ context, event }) => {
const center = context.center as Point;
const other = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0];
const ellipse = ellipseOf(context, other);
setGhostHelperEntities(ellipse ? [ellipse] : []);
},
drawByDistance: ({ context, event }) => {
const center = context.center as Point;
add(context, center.distanceTo(getPointFromEvent(center, event as PointInputEvent))[0]);
},
drawByRotation: ({ context, event }) => {
const axis = (context.center as Point).distanceTo(context.axisEnd as Point)[0];
const angle = ((event as NumberInputEvent).value * Math.PI) / 180;
add(context, axis * Math.cos(angle));
},
},
}
);
@@ -1,17 +1,44 @@
/** 해치·그라데이션·경계·영역 — 선택한 객체가 이루는 닫힌 경계를 채우거나 뽑는다 */
import type { Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { assign, createMachine, sendTo } from 'xstate';
import {
autoHatchSpacing,
getHatchAngle,
getHatchSpacing,
getHatchStyle,
} from '../../commands/draw-settings';
import {
OPT,
type OptionSpec,
type PromptSpec,
SELECT_OBJECTS,
rememberSelection,
} from '../../commands/prompt';
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
import { addEntities, getActiveLineColor, setSelectedEntityIds } from '../../state';
import { isPointInPolygon } from '../../helpers/geometry/hatch-lines';
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import {
addEntities,
getActiveLineColor,
getPickableEntities,
getSelectedEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../../state';
import { Tool } from '../../tools';
import { hatchEntity, polyLineEntity } from '../factories/entity-factory';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
import { selectToolStateMachine } from '../select-tool';
import {
type AbsolutePointInputEvent,
ActorEvent,
END_STATE,
type MouseClickEvent,
type StateEvent,
type ToolContext,
} from '../tool.types';
/** 선택 객체에서 닫힌 경계를 얻는다. 못 얻으면 안내 후 빈 배열 */
function loopFromSelection(input: SequenceInput): Point[] {
@@ -29,31 +56,150 @@ function boundingSize(points: Point[]): { width: number; height: number } {
return { width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
}
export const hatchToolStateMachine = createSequenceTool({
tool: Tool.HATCH,
helpers: false,
steps: [
{ kind: 'selection', instructions: '해치를 채울 경계 객체를 선택한 뒤 ENTER를 누르십시오.' },
/** 해치 한 벌 — 지금 해치 설정 · 간격 없으면 경계 크기로 */
function hatchOf(loop: Point[]) {
const { width, height } = boundingSize(loop);
return hatchEntity(loop, {
style: getHatchStyle(),
color: getActiveLineColor(),
spacing: getHatchSpacing() ?? autoHatchSpacing(width, height),
angle: getHatchAngle(),
});
}
const loopArea = (loop: Point[]) =>
Math.abs(
loop.reduce((sum, point, index) => {
const next = loop[(index + 1) % loop.length];
return sum + point.x * next.y - next.x * point.y;
}, 0)
) / 2;
/** 내부 점 — 그 점을 품은 닫힌 객체(닫힌 폴리선 · 사각형 · 원) 중 가장 작은 하나 */
function loopAround(point: Point): { id: string; loop: Point[] } | null {
let best: { id: string; loop: Point[]; area: number } | null = null;
for (const entity of getPickableEntities()) {
const loop = sampleEntityPoints(entity);
if (loop.length < 4) continue;
const [first, last] = [loop[0], loop[loop.length - 1]];
if (Math.hypot(first.x - last.x, first.y - last.y) > 1e-6) continue; // 열린 객체
if (!isPointInPolygon(loop, point)) continue;
const area = loopArea(loop);
if (!best || area < best.area) best = { id: entity.id, loop, area };
}
return best;
}
type HatchContext = ToolContext & { boundaries: { id: string; loop: Point[] }[] };
type HatchArgs = { context: HatchContext; event: StateEvent };
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
const PICK_PROMPT: PromptSpec = {
text: '내부 점 선택',
options: [opt('SELECT', 'S', '객체 선택'), OPT.UNDO],
absent: [
opt('REMOVE', 'B', '경계 제거'),
opt('SETTINGS', 'T', '설정'),
opt('DRAW', 'W', '그리기'),
],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
const { width, height } = boundingSize(loop);
addEntities(
[
hatchEntity(loop, {
style: getHatchStyle(),
color: getActiveLineColor(),
spacing: getHatchSpacing() ?? autoHatchSpacing(width, height),
angle: getHatchAngle(),
}),
],
true
);
setSelectedEntityIds([]);
},
};
const SELECT_CHILD = 'hatchSelect';
const showBoundaries = (boundaries: HatchContext['boundaries']) =>
setGhostHelperEntities(boundaries.flatMap(({ loop }) => polyLineEntity(loop) ?? []));
/** 내부 점 하나 — 경계 못 찾으면 안내만 · 같은 경계 두 번은 한 번 */
const pickAt = assign(({ context, event }: HatchArgs) => {
const at =
event.type === ActorEvent.MOUSE_CLICK
? (event as MouseClickEvent).worldMouseLocation
: (event as AbsolutePointInputEvent).value;
const found = loopAround(at);
if (!found) {
toast.warn('닫힌 경계를 찾을 수 없음.');
return {};
}
if (context.boundaries.some(({ id }) => id === found.id)) return {};
const boundaries = [...context.boundaries, found];
showBoundaries(boundaries);
return { boundaries };
});
const undoPick = assign(({ context }: HatchArgs) => {
const boundaries = context.boundaries.slice(0, -1);
showBoundaries(boundaries);
return { boundaries };
});
/** 모은 경계(+ 고른 객체 경계)마다 해치 하나 */
const createHatches = ({ context }: { context: HatchContext }) => {
const loops = context.boundaries.map(({ loop }) => loop);
const selected = getSelectedEntities();
if (selected.length) {
rememberSelection(selected.map((entity) => entity.id));
const loop = entitiesToLoop(selected);
if (loop.length >= 3) loops.push(loop);
else toast.warn('닫힌 경계를 만들 객체를 선택하십시오.');
}
setGhostHelperEntities([]);
setSelectedEntityIds([]);
if (loops.length) addEntities(loops.map(hatchOf), true);
};
const finish = () => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
};
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신을 품은 상태 구성은 xstate 타입 추론이 안 맞음
const states: Record<string, any> = {
PICK: {
meta: { kind: 'point', prompt: PICK_PROMPT },
entry: () => setShouldDrawHelpers(false),
on: {
MOUSE_CLICK: { actions: pickAt },
ABSOLUTE_POINT_INPUT: { actions: pickAt },
'OPTION.SELECT': { target: 'SELECT' },
'OPTION.UNDO': { actions: undoPick },
ENTER: { actions: createHatches, target: END_STATE },
ESC: { actions: finish, target: END_STATE },
},
},
SELECT: {
meta: { kind: 'selection', prompt: SELECT_OBJECTS },
invoke: {
id: SELECT_CHILD,
src: selectToolStateMachine,
onDone: { actions: createHatches, target: END_STATE },
},
on: {
MOUSE_CLICK: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
ENTER: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
DRAW: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
'OPTION.*': { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
ESC: { actions: finish, target: END_STATE },
},
},
[END_STATE]: { type: 'final' },
};
/**
* HATCH — 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 빈 Enter = 만듦 · Esc = 안 만들고 끝.
* 손으로 짬 — 순서 엔진은 앞 점을 기준점으로 직교를 걸어 둘째 내부 점이 끌려감.
*/
export const hatchToolStateMachine = createMachine(
{
types: {} as { context: HatchContext; events: StateEvent },
context: { type: Tool.HATCH, boundaries: [] },
initial: 'PICK',
states,
},
{
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
actions: { ...(selectToolStateMachine.implementations.actions as any) },
}
);
export const gradientToolStateMachine = createSequenceTool({
tool: Tool.GRADIENT,
helpers: false,

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