style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량 재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만. - 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100) - `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100). `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외 - 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경) 두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물 폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가 `core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가 실제 포맷 차이를 가리고 있었음. 검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped / 0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음). 포맷터 재실행 시 prettier·biome 모두 변경 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면
|
||||
# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫.
|
||||
B07_DesignDetail/openwebcad/
|
||||
|
||||
# 빌드·산출물·가상환경 — 포맷 대상이 아니다.
|
||||
dist/
|
||||
venv/
|
||||
storage/
|
||||
tmp/
|
||||
graphify-out/
|
||||
@@ -69,15 +69,12 @@ export async function uploadProjectFiles(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/files`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -92,22 +89,19 @@ export async function createUploadSession(
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
}
|
||||
|
||||
@@ -138,21 +132,18 @@ export async function finalizeUploadSession(
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/finalize`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
}
|
||||
|
||||
@@ -160,13 +151,10 @@ export async function fetchUploadStatus(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<UploadStatusResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<UploadStatusResponse>(response);
|
||||
}
|
||||
|
||||
@@ -200,16 +188,11 @@ export interface UploadOverviewResponse {
|
||||
}
|
||||
|
||||
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
||||
export async function fetchUploadOverview(
|
||||
projectId: string,
|
||||
): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<UploadOverviewResponse>(response);
|
||||
}
|
||||
|
||||
@@ -223,15 +206,10 @@ export interface WF1AnalysisStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkWF1AnalysisStatus(
|
||||
projectId: string,
|
||||
): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/status`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<WF1AnalysisStatus>(response);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import {
|
||||
createStepBar,
|
||||
WORKFLOW_STEP_ICONS,
|
||||
} from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchUploadOverview,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
@@ -129,10 +123,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return Array.from(slots.values()).filter((state) => state.file);
|
||||
}
|
||||
|
||||
function setCardState(
|
||||
slot: FileSlot,
|
||||
stateName: "empty" | "selected" | UploadStatus,
|
||||
): void {
|
||||
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
|
||||
const card = cardMap.get(slot);
|
||||
if (!card) return;
|
||||
card.classList.remove(
|
||||
@@ -146,9 +137,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const cssState = stateName === "failed" ? "error" : stateName;
|
||||
card.classList.add(`b03-file__card--${cssState}`);
|
||||
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__card-badge-container",
|
||||
);
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
|
||||
if (badgeContainer) {
|
||||
badgeContainer.replaceChildren();
|
||||
if (stateName === "empty") {
|
||||
@@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!state || !card) return;
|
||||
renderExtensionLabel(card, state);
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
);
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-size",
|
||||
);
|
||||
const progress = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__progress-bar",
|
||||
);
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-bytes",
|
||||
);
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-speed",
|
||||
);
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-eta",
|
||||
);
|
||||
const error = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__error-message",
|
||||
);
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
);
|
||||
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
|
||||
|
||||
const percent = state.file
|
||||
? Math.min(100, (state.progressBytes / state.file.size) * 100)
|
||||
: 0;
|
||||
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
if (fileName)
|
||||
fileName.textContent =
|
||||
state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -271,13 +240,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file)
|
||||
setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else
|
||||
setCardState(
|
||||
slot,
|
||||
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
|
||||
);
|
||||
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
@@ -289,23 +253,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(
|
||||
file: File,
|
||||
state: FileSlotState,
|
||||
): string | null {
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
if (!state.extensions.includes(extension))
|
||||
return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes)
|
||||
return L("B03_File_Error_Size");
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignFileToSlot(
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
@@ -317,19 +273,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(
|
||||
state.slot,
|
||||
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
|
||||
);
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
|
||||
if (state.serverUploaded) {
|
||||
const accepted = await confirmReplaceUpload(
|
||||
L(state.labelKey),
|
||||
state.serverUploaded.name,
|
||||
);
|
||||
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
|
||||
if (!accepted) return;
|
||||
}
|
||||
|
||||
@@ -350,18 +300,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function onFileSelected(
|
||||
selection: readonly File[],
|
||||
targetSlot?: FileSlot,
|
||||
): void {
|
||||
function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void {
|
||||
if (selection.length === 0) return;
|
||||
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
|
||||
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
|
||||
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
|
||||
const files = lasFreeDesign
|
||||
? selection.filter(
|
||||
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
|
||||
)
|
||||
? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name)))
|
||||
: selection;
|
||||
const blocked = files.length !== selection.length;
|
||||
if (blocked && files.length === 0) {
|
||||
@@ -433,8 +378,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
|
||||
return routeIsShapefile();
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
|
||||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||||
return state.isRequired;
|
||||
}
|
||||
@@ -447,14 +391,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) =>
|
||||
isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded)
|
||||
return L("B03_File_Error_Las");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
}
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
@@ -490,8 +432,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
: "prj"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
@@ -533,26 +474,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!card) throw new Error("file-card-template is invalid");
|
||||
card.dataset.slotId = state.slot;
|
||||
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(
|
||||
state.labelKey,
|
||||
);
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
|
||||
renderExtensionLabel(card, state);
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-select",
|
||||
)!;
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
select.textContent = L("B03_File_Card_Select");
|
||||
select.addEventListener("click", () => input.click());
|
||||
input.addEventListener("change", () => {
|
||||
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
|
||||
input.value = "";
|
||||
});
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
)!;
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
|
||||
remove.textContent = "×";
|
||||
remove.title = L("B03_File_Card_Remove");
|
||||
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
|
||||
@@ -599,9 +532,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(
|
||||
makeSessionKey(activeProjectId, state.file!),
|
||||
);
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
@@ -671,8 +602,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
@@ -701,9 +631,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startChunkedUpload(
|
||||
targetStates = selectedStates(),
|
||||
): Promise<void> {
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
if (isUploading) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
@@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find(
|
||||
(state) => state.uploadStatus === "uploading",
|
||||
);
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
@@ -789,9 +714,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
onFileSelected(
|
||||
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
|
||||
);
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -803,9 +726,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () =>
|
||||
dropzone.classList.remove("is-dragging"),
|
||||
);
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
uploadButton = createButton({
|
||||
@@ -872,10 +793,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
lasFreeCheck.addEventListener("change", () => {
|
||||
lasFreeDesign = lasFreeCheck.checked;
|
||||
if (activeProjectId) {
|
||||
localStorage.setItem(
|
||||
`b03_las_free_${activeProjectId}`,
|
||||
lasFreeDesign ? "1" : "0",
|
||||
);
|
||||
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
|
||||
}
|
||||
applyLasFreeState();
|
||||
pageError.textContent = "";
|
||||
@@ -885,13 +803,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
terrainGroup.append(lasFreeRow);
|
||||
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.append(routeGroup);
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.append(terrainGroup);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
|
||||
@@ -295,9 +295,7 @@
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(
|
||||
--spacing-8
|
||||
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
@@ -348,9 +346,7 @@
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(
|
||||
--spacing-8
|
||||
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,40 +6,16 @@ import { ui_locales } from "@ui/ui_template_locale";
|
||||
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
|
||||
*/
|
||||
export type FileSlot =
|
||||
| "csv"
|
||||
| "shx"
|
||||
| "dbf"
|
||||
| "cpg"
|
||||
| "route_prj"
|
||||
| "las_laz"
|
||||
| "prj"
|
||||
| "tfw"
|
||||
| "tif"
|
||||
| "dxf";
|
||||
"csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
|
||||
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = [
|
||||
"csv",
|
||||
"shx",
|
||||
"dbf",
|
||||
"cpg",
|
||||
"route_prj",
|
||||
];
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = ["csv", "shx", "dbf", "cpg", "route_prj"];
|
||||
|
||||
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = [
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
];
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = ["las_laz", "prj", "tfw", "tif"];
|
||||
|
||||
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
|
||||
"shx",
|
||||
"dbf",
|
||||
"route_prj",
|
||||
];
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = ["shx", "dbf", "route_prj"];
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -179,9 +155,7 @@ export function planSlotAssignments(
|
||||
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
|
||||
}
|
||||
const config = slotConfigs.find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
);
|
||||
return { file, slot: config?.slot };
|
||||
});
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
@@ -24,10 +18,7 @@ import {
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
|
||||
import {
|
||||
makeSessionKey,
|
||||
type FileSlotState,
|
||||
@@ -42,10 +33,7 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
export function confirmReplaceUpload(
|
||||
slotLabel: string,
|
||||
fileName: string,
|
||||
): Promise<boolean> {
|
||||
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
@@ -120,9 +108,7 @@ export async function uploadOneFile(
|
||||
|
||||
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
|
||||
const fingerprint = state.uploadSessionId
|
||||
? null
|
||||
: await fileFingerprint(file);
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
@@ -156,22 +142,11 @@ export async function uploadOneFile(
|
||||
const start = chunkIndex * chunkSizeBytes;
|
||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||
const chunkStartedAt = performance.now();
|
||||
await uploadFileChunk(
|
||||
projectId,
|
||||
session,
|
||||
chunkIndex,
|
||||
file.slice(start, end),
|
||||
);
|
||||
const elapsedSec = Math.max(
|
||||
0.001,
|
||||
(performance.now() - chunkStartedAt) / 1000,
|
||||
);
|
||||
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
|
||||
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
|
||||
state.progressBytes = end;
|
||||
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
|
||||
state.etaSeconds =
|
||||
state.speedMbs > 0
|
||||
? (file.size - end) / 1024 / 1024 / state.speedMbs
|
||||
: null;
|
||||
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
|
||||
|
||||
const stored: StoredUploadSession = {
|
||||
key: storageKey,
|
||||
@@ -188,10 +163,7 @@ export async function uploadOneFile(
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const now = performance.now();
|
||||
if (
|
||||
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
|
||||
chunkIndex === totalChunks - 1
|
||||
) {
|
||||
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
|
||||
lastPaintAt = now;
|
||||
onProgress();
|
||||
}
|
||||
@@ -213,10 +185,7 @@ export async function uploadOneFile(
|
||||
});
|
||||
state.progressBytes = file.size;
|
||||
state.speedMbs =
|
||||
file.size /
|
||||
1024 /
|
||||
1024 /
|
||||
Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
state.etaSeconds = 0;
|
||||
state.uploadStatus = "completed";
|
||||
onProgress();
|
||||
@@ -237,12 +206,9 @@ export async function uploadOneFile(
|
||||
*
|
||||
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
|
||||
*/
|
||||
export function isInitialPipelineRunning(
|
||||
state: WorkflowState | undefined,
|
||||
): boolean {
|
||||
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
|
||||
if (!state?.stages?.length) return false;
|
||||
const stageAt = (stageNo: number) =>
|
||||
state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const fileInput = stageAt(0);
|
||||
const preprocess = stageAt(1);
|
||||
const section = stageAt(3);
|
||||
|
||||
@@ -8,10 +8,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
||||
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
||||
import type {
|
||||
SurfaceBounds,
|
||||
SurfaceModelSummary,
|
||||
} from "./B04_PreProcess_Api_Fetch";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
@@ -43,11 +40,7 @@ export interface SurfaceTerrainViewer {
|
||||
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
|
||||
setSelection: (sourceFilter: string, method: string) => void;
|
||||
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
|
||||
showOverlay: (
|
||||
sourceFilter: string,
|
||||
method: string,
|
||||
smooth: boolean,
|
||||
) => Promise<boolean>;
|
||||
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
||||
@@ -242,12 +235,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
scene.background = new THREE.Color(color);
|
||||
});
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
SURFACE_CAMERA_FOV,
|
||||
1,
|
||||
0.01,
|
||||
100000,
|
||||
);
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
|
||||
@@ -291,8 +279,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
function disposeObject(obj: THREE.Object3D) {
|
||||
obj.traverse((child) => {
|
||||
const renderable = child as
|
||||
THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
renderable.geometry?.dispose();
|
||||
const material = renderable.material;
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||
@@ -414,9 +401,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(routeLineColor()),
|
||||
});
|
||||
routeGroup.add(
|
||||
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
|
||||
);
|
||||
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
|
||||
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
|
||||
if (terrainMesh) fitCamera(terrainMesh);
|
||||
}
|
||||
@@ -459,8 +444,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
const fitCamera = (object: THREE.Object3D) => {
|
||||
const { span } = getFitParams(object);
|
||||
const aspect =
|
||||
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const bounds = fitBounds();
|
||||
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
|
||||
controls.target.set(0, 0, 0);
|
||||
@@ -524,15 +508,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
||||
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
||||
const match = currentModelsList.find((m) => {
|
||||
const typeMatches =
|
||||
m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const configuredFilter = m.generation_params?.source_filter;
|
||||
const filterMatches =
|
||||
(typeof configuredFilter === "string" &&
|
||||
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
|
||||
Boolean(
|
||||
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
|
||||
);
|
||||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
|
||||
return typeMatches && filterMatches;
|
||||
});
|
||||
|
||||
@@ -543,8 +524,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
|
||||
const modelId = match.id;
|
||||
const isSmooth =
|
||||
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
currentModelId = modelId;
|
||||
currentModelSmooth = isSmooth;
|
||||
const generation = ++loadGeneration;
|
||||
@@ -591,8 +571,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
child.material.vertexColors =
|
||||
child.geometry.hasAttribute("color");
|
||||
child.material.vertexColors = child.geometry.hasAttribute("color");
|
||||
}
|
||||
});
|
||||
gltf.scene.visible = surfCheck.checked;
|
||||
@@ -605,8 +584,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
},
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent =
|
||||
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
showProgress(null, null);
|
||||
},
|
||||
);
|
||||
@@ -817,26 +795,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.hidden = false;
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(
|
||||
dist,
|
||||
viewerArea.clientHeight,
|
||||
);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
const prettyMeters = niceScaleDistance(roughMeters);
|
||||
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
||||
scaleLabel.textContent =
|
||||
prettyMeters >= 1000
|
||||
? `${(prettyMeters / 1000).toFixed(0)} km`
|
||||
: `${prettyMeters} m`;
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.hidden = true;
|
||||
}
|
||||
|
||||
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
||||
if (
|
||||
labelsDirty ||
|
||||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
|
||||
) {
|
||||
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
|
||||
labelsDirty = false;
|
||||
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
|
||||
labelElements.forEach((label) => {
|
||||
@@ -878,8 +848,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
intervalForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const interval = Number(intervalInput.value);
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
|
||||
return;
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
|
||||
intervalSubmit.disabled = true;
|
||||
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
||||
intervalSubmit.disabled = false;
|
||||
@@ -928,13 +897,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
clearOverlay();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return loadOverlay(
|
||||
currentProjectId,
|
||||
currentModelsList,
|
||||
sourceFilter,
|
||||
method,
|
||||
smooth,
|
||||
);
|
||||
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
@@ -951,8 +914,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
setContourInterval(interval) {
|
||||
if (Number.isFinite(interval) && interval > 0)
|
||||
intervalInput.value = String(interval);
|
||||
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
|
||||
},
|
||||
getContourInterval() {
|
||||
return Number.parseFloat(intervalInput.value);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
export interface DesignDrawingItem {
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
@@ -68,10 +67,7 @@ export interface CrossDesignInfo {
|
||||
cross_slope_pct?: number;
|
||||
paved?: boolean;
|
||||
ditch: DitchSpec;
|
||||
road_edges?: Record<
|
||||
"left" | "right",
|
||||
{ offset_m: number; elevation_m: number }
|
||||
>;
|
||||
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
@@ -84,8 +80,7 @@ export interface DesignDrawingResponse {
|
||||
route_id: number;
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
@@ -102,10 +97,7 @@ export interface DesignDrawingConfirmResponse {
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -116,17 +108,14 @@ async function requestJson<T>(
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok)
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchDesignDrawingList(
|
||||
projectId: string,
|
||||
): Promise<DesignDrawingListResponse> {
|
||||
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings`);
|
||||
}
|
||||
|
||||
@@ -134,9 +123,7 @@ export function fetchDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<DesignDrawingResponse> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
|
||||
);
|
||||
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
|
||||
}
|
||||
|
||||
export function confirmDesignDrawing(
|
||||
@@ -154,10 +141,7 @@ export function confirmDesignDrawing(
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidateDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<void> {
|
||||
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
||||
{ method: "POST" },
|
||||
@@ -173,16 +157,11 @@ export interface FrameTemplateResponse {
|
||||
customized: boolean;
|
||||
}
|
||||
|
||||
export function fetchFrameTemplate(
|
||||
projectId: string,
|
||||
): Promise<FrameTemplateResponse> {
|
||||
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`);
|
||||
}
|
||||
|
||||
export function saveFrameTemplate(
|
||||
projectId: string,
|
||||
drawing: CadDrawing,
|
||||
): Promise<void> {
|
||||
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ drawing }),
|
||||
|
||||
@@ -37,9 +37,7 @@ interface Options {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function createFrameTemplateEditor(
|
||||
options: Options,
|
||||
): FrameTemplateEditor {
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
|
||||
const banner = document.createElement("div");
|
||||
@@ -94,10 +92,7 @@ export function createFrameTemplateEditor(
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +112,7 @@ export function createFrameTemplateEditor(
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "기본 도각으로 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "기본 도각으로 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -134,16 +127,10 @@ export function createFrameTemplateEditor(
|
||||
const drawing = await options.requestCadDrawing();
|
||||
await saveFrameTemplate(options.projectId, drawing);
|
||||
options.onSaved();
|
||||
showToast(
|
||||
"도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.",
|
||||
"success",
|
||||
);
|
||||
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 저장하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 저장하지 못했습니다.", "error");
|
||||
} finally {
|
||||
finishButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const drawingButton = (
|
||||
drawing: DesignDrawingItem,
|
||||
label: string,
|
||||
): HTMLButtonElement => {
|
||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
@@ -175,8 +172,7 @@ function buildDrawingSidePanel(
|
||||
const button = drawingButton(drawing, group.label);
|
||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||
button.dataset.pending = String(drawing.kind === "blank");
|
||||
if (drawing.kind === "blank")
|
||||
button.title = "준비 중 — 도각만 표시합니다";
|
||||
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
@@ -198,10 +194,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const GROUND_TYPE_LABEL: Record<
|
||||
CrossDesignInfo["ground_type"],
|
||||
keyof typeof ui_locales
|
||||
> = {
|
||||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||||
soil: "B06_Design_Ground_Soil",
|
||||
ripping_rock: "B06_Design_Ground_Ripping",
|
||||
blasting_rock: "B06_Design_Ground_Blasting",
|
||||
@@ -218,8 +211,7 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
|
||||
return "없음";
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
||||
if (ditch.type === "l_type")
|
||||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
@@ -239,10 +231,7 @@ function infoRow(label: string, value: string): HTMLElement {
|
||||
}
|
||||
|
||||
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
|
||||
function buildDesignInfoPanel(
|
||||
title: string,
|
||||
design: CrossDesignInfo | null,
|
||||
): HTMLElement {
|
||||
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-info";
|
||||
const heading = document.createElement("div");
|
||||
@@ -252,9 +241,7 @@ function buildDesignInfoPanel(
|
||||
const confirmed = design?.status === "confirmed";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
||||
badge.textContent = confirmed
|
||||
? L("B07_Info_Confirmed")
|
||||
: L("B07_Info_Provisional");
|
||||
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
|
||||
heading.append(stationName, badge);
|
||||
panel.append(heading);
|
||||
|
||||
@@ -276,9 +263,7 @@ function buildDesignInfoPanel(
|
||||
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
||||
infoRow(
|
||||
L("B07_Info_DitchSide"),
|
||||
design.ditch_side === "left"
|
||||
? L("B06_Design_Ditch_Left")
|
||||
: L("B06_Design_Ditch_Right"),
|
||||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -288,10 +273,7 @@ function buildDesignInfoPanel(
|
||||
planTitle.textContent = L("B07_Info_Plan_Title");
|
||||
plan.append(
|
||||
planTitle,
|
||||
infoRow(
|
||||
L("B07_Info_DesignElevation"),
|
||||
`${design.design_elevation_m.toFixed(2)}m`,
|
||||
),
|
||||
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
|
||||
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
||||
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
||||
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
||||
@@ -317,10 +299,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
fetchWorkflowState(projectId),
|
||||
fetchDesignDrawingList(projectId),
|
||||
]);
|
||||
if (workflowResult.status === "fulfilled")
|
||||
workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled")
|
||||
drawings = drawingResult.value.drawings;
|
||||
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
|
||||
else
|
||||
drawingError =
|
||||
drawingResult.reason instanceof Error
|
||||
@@ -350,25 +330,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) &&
|
||||
drawings.filter(isCross).every((item) => item.confirmed);
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
|
||||
const updateInfoPanel = (
|
||||
drawing: DesignDrawingItem,
|
||||
response: DesignDrawingResponse,
|
||||
): void => {
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const title = drawing.label;
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(title, response.design ?? null),
|
||||
);
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -406,11 +380,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
) ?? undefined;
|
||||
|
||||
const highlightActive = (drawingId: string) => {
|
||||
drawingListEl
|
||||
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button")
|
||||
.forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
};
|
||||
|
||||
const buildMeta = (
|
||||
@@ -449,24 +421,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||||
|
||||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||||
const requestDrawing = (
|
||||
drawing: DesignDrawingItem,
|
||||
): Promise<DesignDrawingResponse> => {
|
||||
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
|
||||
const cached = drawingCache.get(drawing.id);
|
||||
if (cached) return cached;
|
||||
const request = (async () => {
|
||||
const response = await fetchDesignDrawing(
|
||||
projectId as string,
|
||||
drawing.id,
|
||||
);
|
||||
const response = await fetchDesignDrawing(projectId as string, drawing.id);
|
||||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||||
if (drawing.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(
|
||||
projectId as string,
|
||||
response.route_id,
|
||||
response.drawing,
|
||||
);
|
||||
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
|
||||
}
|
||||
return response;
|
||||
})().catch((error) => {
|
||||
@@ -522,9 +485,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} catch (error) {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "CAD 도면을 불러오지 못했습니다.";
|
||||
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
@@ -549,10 +510,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_SAVE_REQUEST_MESSAGE },
|
||||
window.location.origin,
|
||||
);
|
||||
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
@@ -596,9 +554,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "현재 도면을 확정하지 못했습니다.",
|
||||
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -628,9 +584,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -650,11 +604,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.prepend(frameEditor.banner);
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frame.contentWindow
|
||||
)
|
||||
return;
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
@@ -672,8 +622,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration =
|
||||
message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
@@ -693,8 +642,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
cadHost.dataset.error =
|
||||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.loading = "false";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
@@ -704,11 +652,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (
|
||||
message.type === CAD_SAVE_RESPONSE_MESSAGE &&
|
||||
message.drawing &&
|
||||
resolveSave
|
||||
) {
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({
|
||||
@@ -718,11 +662,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
);
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
@@ -744,10 +684,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
onStepClick: (stepIndex) => {
|
||||
if (!projectId) return;
|
||||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||||
showToast(
|
||||
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
|
||||
"warning",
|
||||
);
|
||||
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
|
||||
@@ -116,11 +116,7 @@
|
||||
|
||||
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
|
||||
.b07-drawing-button[data-confirmed="true"] {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-success) 35%,
|
||||
var(--color-border)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
wipeoutToolStateMachine,
|
||||
xlineToolStateMachine,
|
||||
} from '../tools/draw/construction-tools';
|
||||
import {
|
||||
divideToolStateMachine,
|
||||
measureLengthToolStateMachine,
|
||||
} from '../tools/draw/divide-tools';
|
||||
import { divideToolStateMachine, measureLengthToolStateMachine } from '../tools/draw/divide-tools';
|
||||
import {
|
||||
boundaryToolStateMachine,
|
||||
gradientToolStateMachine,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {noop} from 'es-toolkit';
|
||||
import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react';
|
||||
import {Icon, type IconName} from './Icon/Icon.tsx';
|
||||
import { noop } from 'es-toolkit';
|
||||
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
|
||||
import { Icon, type IconName } from './Icon/Icon.tsx';
|
||||
|
||||
interface ButtonProps {
|
||||
label?: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {CSSProperties, FC, ReactNode} from 'react';
|
||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
import {LOCAL_STORAGE_KEY} from '../App.types.ts';
|
||||
import {keyboardHandler} from '../helpers/keyboard-handler.ts';
|
||||
import {Button} from './Button.tsx';
|
||||
import {Icon, IconName} from './Icon/Icon.tsx';
|
||||
import { LOCAL_STORAGE_KEY } from '../App.types.ts';
|
||||
import { keyboardHandler } from '../helpers/keyboard-handler.ts';
|
||||
import { Button } from './Button.tsx';
|
||||
import { Icon, IconName } from './Icon/Icon.tsx';
|
||||
|
||||
interface DropdownButtonProps {
|
||||
label?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {FC} from 'react';
|
||||
import type { FC } from 'react';
|
||||
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
|
||||
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
|
||||
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
|
||||
|
||||
@@ -3,12 +3,7 @@ import type { FC } from 'react';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { PropertiesEditor } from './PropertiesEditor';
|
||||
import { getInspectorTab, openInspector } from './ui-state';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setLayers,
|
||||
} from '../state';
|
||||
import { getActiveLayerId, getLayers, setActiveLayerId, setLayers } from '../state';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
collapsed: boolean;
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import {
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
|
||||
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
|
||||
|
||||
interface PropertiesEditorProps {
|
||||
@@ -139,9 +134,7 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
</div>
|
||||
<div>
|
||||
<dt>시작점</dt>
|
||||
<dd>
|
||||
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
|
||||
</dd>
|
||||
<dd>{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>그룹</dt>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Point, Vector} from '@flatten-js/core';
|
||||
import {toast} from 'react-toastify';
|
||||
import {SVG_MARGIN, TO_DEGREES} from '../App.consts.ts';
|
||||
import type {TextOptions} from '../entities/TextEntity.ts';
|
||||
import {isLengthEqual} from '../helpers/is-length-equal.ts';
|
||||
import {StateVariable} from '../helpers/undo-stack.ts';
|
||||
import {triggerReactUpdate} from '../state.ts';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { SVG_MARGIN, TO_DEGREES } from '../App.consts.ts';
|
||||
import type { TextOptions } from '../entities/TextEntity.ts';
|
||||
import { isLengthEqual } from '../helpers/is-length-equal.ts';
|
||||
import { StateVariable } from '../helpers/undo-stack.ts';
|
||||
import { triggerReactUpdate } from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
|
||||
export class SvgDrawController implements DrawController {
|
||||
private lineColor = '#000';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type Arc, Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {EPSILON} from "../App.consts.ts";
|
||||
import {ArcEntity} from './ArcEntity.ts';
|
||||
import { type Arc, Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EPSILON } from '../App.consts.ts';
|
||||
import { ArcEntity } from './ArcEntity.ts';
|
||||
|
||||
describe('ArcEntity.distanceTo', () => {
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,13 @@ export class HatchEntity implements Entity {
|
||||
}
|
||||
|
||||
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
|
||||
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
||||
drawController.setLineStyles(
|
||||
highlighted,
|
||||
selected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
for (let index = 1; index < this.points.length; index++) {
|
||||
drawController.drawLine(this.points[index - 1], this.points[index]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {LineEntity} from "./LineEntity.ts";
|
||||
import {TO_DEGREES} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { LineEntity } from './LineEntity.ts';
|
||||
import { TO_DEGREES } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
describe('getAngle', () => {
|
||||
it('should return 0 for a horizontal line', () => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {type Box, Line, Point, Vector} from '@flatten-js/core'; // Added Box, Segment for completeness
|
||||
import {round} from 'es-toolkit'; // 1. Mocking for ../state.ts
|
||||
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest';
|
||||
import {EPSILON, MEASUREMENT_DECIMAL_PLACES, MEASUREMENT_FONT_SIZE, MEASUREMENT_LABEL_OFFSET,} from '../App.consts';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
|
||||
import {isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {MeasurementEntity} from './MeasurementEntity';
|
||||
import { type Box, Line, Point, Vector } from '@flatten-js/core'; // Added Box, Segment for completeness
|
||||
import { round } from 'es-toolkit'; // 1. Mocking for ../state.ts
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import {
|
||||
EPSILON,
|
||||
MEASUREMENT_DECIMAL_PLACES,
|
||||
MEASUREMENT_FONT_SIZE,
|
||||
MEASUREMENT_LABEL_OFFSET,
|
||||
} from '../App.consts';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
|
||||
import { isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { MeasurementEntity } from './MeasurementEntity';
|
||||
|
||||
// 1. Mocking for ../state.ts
|
||||
vi.mock('../state.ts', () => ({
|
||||
|
||||
@@ -301,9 +301,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 = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
if (
|
||||
@@ -418,9 +416,7 @@ export class MeasurementEntity implements Entity {
|
||||
];
|
||||
|
||||
// Calculate text properties
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
|
||||
);
|
||||
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = getDimTextHeight() / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { type Box, Point, Polygon } from '@flatten-js/core';
|
||||
|
||||
export function boxToPolygon(box: Box): Polygon {
|
||||
return new Polygon([
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
]);
|
||||
return new Polygon([
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function twoPointBoxToPolygon(first: Point, second: Point): Polygon {
|
||||
return new Polygon([
|
||||
new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)),
|
||||
new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)),
|
||||
]);
|
||||
return new Polygon([
|
||||
new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)),
|
||||
new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ export function pasteFromClipboard(target?: Point): Entity[] {
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
if (target) {
|
||||
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
|
||||
copy.move(
|
||||
target.x - (clipboard as ClipboardContent).basePoint.x,
|
||||
target.y - (clipboard as ClipboardContent).basePoint.y
|
||||
);
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
getAngleGuideOriginPoint,
|
||||
getAngleStep,
|
||||
getHoveredSnapPoints,
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getShouldDrawHelpers,
|
||||
getSnapTrackingEnabled,
|
||||
setAngleGuideEntities,
|
||||
setSnapPoint,
|
||||
setSnapPointOnAngleGuide,
|
||||
getAngleGuideOriginPoint,
|
||||
getAngleStep,
|
||||
getHoveredSnapPoints,
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getShouldDrawHelpers,
|
||||
getSnapTrackingEnabled,
|
||||
setAngleGuideEntities,
|
||||
setSnapPoint,
|
||||
setSnapPointOnAngleGuide,
|
||||
} from '../state.ts';
|
||||
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
|
||||
import { getDrawHelpers } from './get-draw-guides.ts';
|
||||
@@ -19,42 +19,41 @@ import { compact } from 'es-toolkit';
|
||||
* Calculate angle guides and snap points
|
||||
*/
|
||||
export function calculateAngleGuidesAndSnapPoints() {
|
||||
const angleStep = getAngleStep();
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
const screenScale = screenCanvasDrawController.getScreenScale();
|
||||
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
|
||||
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 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);
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
const angleStep = getAngleStep();
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
const screenScale = screenCanvasDrawController.getScreenScale();
|
||||
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
|
||||
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 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);
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
hoveredSnapPoint =>
|
||||
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
|
||||
)
|
||||
: [];
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
|
||||
)
|
||||
: [];
|
||||
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
|
||||
);
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
|
||||
);
|
||||
|
||||
if (getShouldDrawHelpers()) {
|
||||
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
|
||||
entities,
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance,
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
setSnapPointOnAngleGuide(angleSnapPoint);
|
||||
}
|
||||
if (getShouldDrawHelpers()) {
|
||||
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
|
||||
entities,
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
setSnapPointOnAngleGuide(angleSnapPoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,167 +2,167 @@ import { describe, expect, it } from 'vitest';
|
||||
import { containRectangle } from './contain-rect.ts';
|
||||
|
||||
describe('containRectangle', () => {
|
||||
it('scales down a larger rectangle to fit into a smaller wrapper', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // contained: a 200x200 square
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: a 100x100 square
|
||||
);
|
||||
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
|
||||
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
|
||||
});
|
||||
it('scales down a larger rectangle to fit into a smaller wrapper', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // contained: a 200x200 square
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100 // wrapper: a 100x100 square
|
||||
);
|
||||
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
|
||||
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
|
||||
});
|
||||
|
||||
it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper: 200x200
|
||||
);
|
||||
// Expected: scale up by factor of 4 to fill as much space as possible while containing
|
||||
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 });
|
||||
});
|
||||
it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200 // wrapper: 200x200
|
||||
);
|
||||
// Expected: scale up by factor of 4 to fill as much space as possible while containing
|
||||
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 });
|
||||
});
|
||||
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is square', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50 square
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
100, // wrapper: 200x100
|
||||
);
|
||||
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
|
||||
// Min scale = 2, so final size = 100x100.
|
||||
// Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset.
|
||||
// Result = (50,0) to (150,100)
|
||||
expect(result.minX).toBeCloseTo(50);
|
||||
expect(result.minY).toBeCloseTo(0);
|
||||
expect(result.maxX).toBeCloseTo(150);
|
||||
expect(result.maxY).toBeCloseTo(100);
|
||||
});
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is square', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50 square
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
100 // wrapper: 200x100
|
||||
);
|
||||
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
|
||||
// Min scale = 2, so final size = 100x100.
|
||||
// Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset.
|
||||
// Result = (50,0) to (150,100)
|
||||
expect(result.minX).toBeCloseTo(50);
|
||||
expect(result.minY).toBeCloseTo(0);
|
||||
expect(result.maxX).toBeCloseTo(150);
|
||||
expect(result.maxY).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
50, // contained: 200x50
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
100, // wrapper: 300x100
|
||||
);
|
||||
// Contained AR = 200/50 = 4:1
|
||||
// Wrapper AR = 300/100 = 3:1
|
||||
// To fit inside 300x100:
|
||||
// Scale factors: width scale = 300/200=1.5, height scale=100/50=2.
|
||||
// min scale = 1.5
|
||||
// Final size: 200*1.5=300 width, 50*1.5=75 height
|
||||
// Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully
|
||||
expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 });
|
||||
});
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
50, // contained: 200x50
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
100 // wrapper: 300x100
|
||||
);
|
||||
// Contained AR = 200/50 = 4:1
|
||||
// Wrapper AR = 300/100 = 3:1
|
||||
// To fit inside 300x100:
|
||||
// Scale factors: width scale = 300/200=1.5, height scale=100/50=2.
|
||||
// min scale = 1.5
|
||||
// Final size: 200*1.5=300 width, 50*1.5=75 height
|
||||
// Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully
|
||||
expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 });
|
||||
});
|
||||
|
||||
it('handles zero-width/height contained rectangle gracefully', () => {
|
||||
// Contained rectangle is essentially a line or point
|
||||
const result = containRectangle(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
10, // contained has 0 width/height
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper
|
||||
);
|
||||
// Center as a single point at (100,100)
|
||||
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
|
||||
});
|
||||
it('handles zero-width/height contained rectangle gracefully', () => {
|
||||
// Contained rectangle is essentially a line or point
|
||||
const result = containRectangle(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
10, // contained has 0 width/height
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200 // wrapper
|
||||
);
|
||||
// Center as a single point at (100,100)
|
||||
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
|
||||
});
|
||||
|
||||
it('does not scale if contained rectangle already fits', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // contained fits easily
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300, // wrapper
|
||||
);
|
||||
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
|
||||
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
|
||||
// So final size is 300x300, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 });
|
||||
});
|
||||
it('does not scale if contained rectangle already fits', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // contained fits easily
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300 // wrapper
|
||||
);
|
||||
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
|
||||
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
|
||||
// So final size is 300x300, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 });
|
||||
});
|
||||
|
||||
it('correctly centers when wrapper and contained have different origins', () => {
|
||||
const result = containRectangle(
|
||||
5,
|
||||
5,
|
||||
15,
|
||||
35, // contained: 10 wide x 30 tall
|
||||
10,
|
||||
20,
|
||||
110,
|
||||
220, // wrapper: 100x200
|
||||
);
|
||||
// Wrapper size: 100x200
|
||||
// Contained size: 10x30
|
||||
// Scale factors: width scale = 100/10=10, height scale=200/30 ≈ 6.666...
|
||||
// min scale = 6.666...
|
||||
// Final size: width = 10 * 6.666... ≈ 66.666..., height = 30 * 6.666... ≈ 200
|
||||
// After scaling, top-left corner should be placed so it centers:
|
||||
// Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minX≈26.666...
|
||||
// Vertical center: fits height exactly, so minY=20, maxY=20+200=220
|
||||
expect(result.minX).toBeCloseTo(26.6667);
|
||||
expect(result.minY).toBeCloseTo(20);
|
||||
expect(result.maxX).toBeCloseTo(93.3333);
|
||||
expect(result.maxY).toBeCloseTo(220);
|
||||
});
|
||||
it('correctly centers when wrapper and contained have different origins', () => {
|
||||
const result = containRectangle(
|
||||
5,
|
||||
5,
|
||||
15,
|
||||
35, // contained: 10 wide x 30 tall
|
||||
10,
|
||||
20,
|
||||
110,
|
||||
220 // wrapper: 100x200
|
||||
);
|
||||
// Wrapper size: 100x200
|
||||
// Contained size: 10x30
|
||||
// Scale factors: width scale = 100/10=10, height scale=200/30 ≈ 6.666...
|
||||
// min scale = 6.666...
|
||||
// Final size: width = 10 * 6.666... ≈ 66.666..., height = 30 * 6.666... ≈ 200
|
||||
// After scaling, top-left corner should be placed so it centers:
|
||||
// Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minX≈26.666...
|
||||
// Vertical center: fits height exactly, so minY=20, maxY=20+200=220
|
||||
expect(result.minX).toBeCloseTo(26.6667);
|
||||
expect(result.minY).toBeCloseTo(20);
|
||||
expect(result.maxX).toBeCloseTo(93.3333);
|
||||
expect(result.maxY).toBeCloseTo(220);
|
||||
});
|
||||
|
||||
it('handles negative coordinates in wrapper and contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
-100,
|
||||
-50,
|
||||
100,
|
||||
50, // wrapper: 200 wide x 100 tall
|
||||
);
|
||||
// Scale factors: width scale = 200/100=2, height scale=100/50=2
|
||||
// min scale = 2, final size: 200x100 exactly.
|
||||
// Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y)
|
||||
// After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50
|
||||
expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 });
|
||||
});
|
||||
it('handles negative coordinates in wrapper and contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
-100,
|
||||
-50,
|
||||
100,
|
||||
50 // wrapper: 200 wide x 100 tall
|
||||
);
|
||||
// Scale factors: width scale = 200/100=2, height scale=100/50=2
|
||||
// min scale = 2, final size: 200x100 exactly.
|
||||
// Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y)
|
||||
// After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50
|
||||
expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 });
|
||||
});
|
||||
|
||||
it('handles negative coordinates in contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: 100 wide x 100 tall
|
||||
);
|
||||
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
|
||||
});
|
||||
it('handles negative coordinates in contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100 // wrapper: 100 wide x 100 tall
|
||||
);
|
||||
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
export function containRectangle(
|
||||
containedRectMinX: number,
|
||||
containedRectMinY: number,
|
||||
containedRectMaxX: number,
|
||||
containedRectMaxY: number,
|
||||
wrapperRectMinX: number,
|
||||
wrapperRectMinY: number,
|
||||
wrapperRectMaxX: number,
|
||||
wrapperRectMaxY: number,
|
||||
containedRectMinX: number,
|
||||
containedRectMinY: number,
|
||||
containedRectMaxX: number,
|
||||
containedRectMaxY: number,
|
||||
wrapperRectMinX: number,
|
||||
wrapperRectMinY: number,
|
||||
wrapperRectMaxX: number,
|
||||
wrapperRectMaxY: number
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
// Calculate the width and height of the wrapper rectangle
|
||||
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
|
||||
const wrapperHeight = wrapperRectMaxY - wrapperRectMinY;
|
||||
// Calculate the width and height of the wrapper rectangle
|
||||
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
|
||||
const wrapperHeight = wrapperRectMaxY - wrapperRectMinY;
|
||||
|
||||
// Calculate the width and height of the contained rectangle
|
||||
const containedWidth = containedRectMaxX - containedRectMinX;
|
||||
const containedHeight = containedRectMaxY - containedRectMinY;
|
||||
// Calculate the width and height of the contained rectangle
|
||||
const containedWidth = containedRectMaxX - containedRectMinX;
|
||||
const containedHeight = containedRectMaxY - containedRectMinY;
|
||||
|
||||
// Edge case: if contained dimensions are zero, just center as a point
|
||||
if (containedWidth === 0 || containedHeight === 0) {
|
||||
const centerX = wrapperRectMinX + wrapperWidth / 2;
|
||||
const centerY = wrapperRectMinY + wrapperHeight / 2;
|
||||
return {
|
||||
minX: centerX,
|
||||
minY: centerY,
|
||||
maxX: centerX,
|
||||
maxY: centerY,
|
||||
};
|
||||
}
|
||||
// Edge case: if contained dimensions are zero, just center as a point
|
||||
if (containedWidth === 0 || containedHeight === 0) {
|
||||
const centerX = wrapperRectMinX + wrapperWidth / 2;
|
||||
const centerY = wrapperRectMinY + wrapperHeight / 2;
|
||||
return {
|
||||
minX: centerX,
|
||||
minY: centerY,
|
||||
maxX: centerX,
|
||||
maxY: centerY,
|
||||
};
|
||||
}
|
||||
|
||||
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
wrapperWidth / containedWidth,
|
||||
wrapperHeight / containedHeight,
|
||||
);
|
||||
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
|
||||
const scale = Math.min(wrapperWidth / containedWidth, wrapperHeight / containedHeight);
|
||||
|
||||
// Compute final displayed dimensions
|
||||
const displayWidth = containedWidth * scale;
|
||||
const displayHeight = containedHeight * scale;
|
||||
// Compute final displayed dimensions
|
||||
const displayWidth = containedWidth * scale;
|
||||
const displayHeight = containedHeight * scale;
|
||||
|
||||
// Compute offsets to center the scaled rectangle
|
||||
const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2;
|
||||
const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2;
|
||||
// Compute offsets to center the scaled rectangle
|
||||
const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2;
|
||||
const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2;
|
||||
|
||||
// Return the final coordinates of the scaled and centered rectangle
|
||||
return {
|
||||
minX: offsetX,
|
||||
minY: offsetY,
|
||||
maxX: offsetX + displayWidth,
|
||||
maxY: offsetY + displayHeight,
|
||||
};
|
||||
// Return the final coordinates of the scaled and centered rectangle
|
||||
return {
|
||||
minX: offsetX,
|
||||
minY: offsetY,
|
||||
maxX: offsetX + displayWidth,
|
||||
maxY: offsetY + displayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,17 +5,13 @@ describe('svgPathToSegments', () => {
|
||||
it('should handle simple move and line commands', () => {
|
||||
const path = 'M 10 10 L 20 20';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle relative line commands', () => {
|
||||
const path = 'M 10 10 l 10 10';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle horizontal and vertical lines', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// A small type alias for clarity.
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {EntityName} from '../entities/Entity.ts';
|
||||
import type {JsonDrawingFileSerialized} from './import-export-handlers/export-entities-to-json.ts';
|
||||
import { EntityName } from '../entities/Entity.ts';
|
||||
import type { JsonDrawingFileSerialized } from './import-export-handlers/export-entities-to-json.ts';
|
||||
|
||||
export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
|
||||
entities: [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {findClosestEntity} from './find-closest-entity';
|
||||
import {arcAndLineEntitiesMock} from "./find-closest-entity.mocks.ts";
|
||||
import {getEntitiesAndLayersFromJsonObject,} from './import-export-handlers/import-entities-from-json.ts';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findClosestEntity } from './find-closest-entity';
|
||||
import { arcAndLineEntitiesMock } from './find-closest-entity.mocks.ts';
|
||||
import { getEntitiesAndLayersFromJsonObject } from './import-export-handlers/import-entities-from-json.ts';
|
||||
|
||||
describe('findClosestEntity', () => {
|
||||
it('should return the arc as the closest entity', async () => {
|
||||
|
||||
@@ -11,34 +11,28 @@ import { sortPointsOnArc } from './sort-points-on-arc';
|
||||
* @param pointsOnShape
|
||||
*/
|
||||
export function findNeighboringPointsOnArc(
|
||||
clickedPointOnShape: Point,
|
||||
arc: ArcEntity,
|
||||
pointsOnShape: Point[],
|
||||
clickedPointOnShape: Point,
|
||||
arc: ArcEntity,
|
||||
pointsOnShape: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnArc(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(arc.getShape() as Arc).center,
|
||||
(arc.getShape() as Arc).start,
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnArc(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(arc.getShape() as Arc).center,
|
||||
(arc.getShape() as Arc).start
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnArc',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnArc');
|
||||
}
|
||||
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
|
||||
],
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
|
||||
],
|
||||
];
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,33 +11,27 @@ import { sortPointsOnCircle } from './sort-points-on-circle';
|
||||
* @param pointsOnShape
|
||||
*/
|
||||
export function findNeighboringPointsOnCircle(
|
||||
clickedPointOnShape: Point,
|
||||
circle: CircleEntity,
|
||||
pointsOnShape: Point[],
|
||||
clickedPointOnShape: Point,
|
||||
circle: CircleEntity,
|
||||
pointsOnShape: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnCircle(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(circle.getShape() as Circle).center,
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnCircle(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(circle.getShape() as Circle).center
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnCircle',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnCircle');
|
||||
}
|
||||
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
|
||||
],
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
|
||||
],
|
||||
];
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,31 +11,26 @@ import { pointDistance } from './distance-between-points';
|
||||
* @param pointsOnLine
|
||||
*/
|
||||
export function findNeighboringPointsOnLine(
|
||||
clickedPointOnLine: Point,
|
||||
lineStartPoint: Point,
|
||||
lineEndPoint: Point,
|
||||
pointsOnLine: Point[],
|
||||
clickedPointOnLine: Point,
|
||||
lineStartPoint: Point,
|
||||
lineEndPoint: Point,
|
||||
pointsOnLine: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortBy(
|
||||
uniqWith(
|
||||
[lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint],
|
||||
isPointEqual,
|
||||
),
|
||||
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)],
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortBy(
|
||||
uniqWith([lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint], isPointEqual),
|
||||
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)]
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnLine, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnLine',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnLine, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnLine');
|
||||
}
|
||||
|
||||
return [
|
||||
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
|
||||
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
|
||||
];
|
||||
return [
|
||||
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
|
||||
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE
|
||||
while (remaining.length) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const index = remaining.findIndex(
|
||||
(chain) =>
|
||||
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
(chain) => near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
);
|
||||
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
|
||||
const [chain] = remaining.splice(index, 1);
|
||||
|
||||
@@ -33,7 +33,10 @@ function sampleCircle(circle: Circle, segments: number): Point[] {
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
points.push(
|
||||
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
|
||||
new Point(
|
||||
circle.center.x + circle.r * Math.cos(angle),
|
||||
circle.center.y + circle.r * Math.sin(angle)
|
||||
)
|
||||
);
|
||||
}
|
||||
return points;
|
||||
@@ -48,9 +51,7 @@ export function dedupeConsecutive(points: Point[]): Point[] {
|
||||
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const children = (entity as PolyLineEntity).getEntities();
|
||||
return dedupeConsecutive(
|
||||
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
|
||||
);
|
||||
return dedupeConsecutive(children.flatMap((child) => sampleEntityPoints(child, curveSegments)));
|
||||
}
|
||||
|
||||
const shape = entity.getShape();
|
||||
|
||||
@@ -71,7 +71,9 @@ export function regularPolygonPoints(center: Point, vertex: Point, sides: number
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const angle = startAngle + (2 * Math.PI * index) / count;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
points.push(points[0].clone());
|
||||
return points;
|
||||
@@ -101,11 +103,7 @@ export function ellipsePoints(
|
||||
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
||||
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
||||
if (controlPoints.length < 3) return [...controlPoints];
|
||||
const extended = [
|
||||
controlPoints[0],
|
||||
...controlPoints,
|
||||
controlPoints[controlPoints.length - 1],
|
||||
];
|
||||
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < extended.length - 2; index++) {
|
||||
const p0 = extended[index - 1];
|
||||
@@ -158,7 +156,9 @@ function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import {times} from './times';
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { times } from './times';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
export function getAngleGuideLines(
|
||||
firstPoint: Point,
|
||||
angleStep: number,
|
||||
): LineEntity[] {
|
||||
// Only for 180 degrees since we draw lines that are infinite in both directions,
|
||||
// so we only need to fill half a circle to fill the complete circle
|
||||
return times(180 / angleStep, i => {
|
||||
const angle = i * angleStep;
|
||||
const angleRad = angle * (Math.PI / 180);
|
||||
const x = firstPoint.x + Math.cos(angleRad);
|
||||
const y = firstPoint.y + Math.sin(angleRad);
|
||||
const angleLine = new LineEntity(getActiveLayerId(),
|
||||
new Point(
|
||||
firstPoint.x - 10000 * (x - firstPoint.x),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y),
|
||||
),
|
||||
new Point(
|
||||
firstPoint.x + 10000 * (x - firstPoint.x),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y),
|
||||
),
|
||||
);
|
||||
angleLine.lineColor = ANGLE_GUIDES_COLOR;
|
||||
angleLine.lineDash = ANGLE_GUIDES_DASH;
|
||||
return angleLine
|
||||
});
|
||||
export function getAngleGuideLines(firstPoint: Point, angleStep: number): LineEntity[] {
|
||||
// Only for 180 degrees since we draw lines that are infinite in both directions,
|
||||
// so we only need to fill half a circle to fill the complete circle
|
||||
return times(180 / angleStep, (i) => {
|
||||
const angle = i * angleStep;
|
||||
const angleRad = angle * (Math.PI / 180);
|
||||
const x = firstPoint.x + Math.cos(angleRad);
|
||||
const y = firstPoint.y + Math.sin(angleRad);
|
||||
const angleLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
new Point(
|
||||
firstPoint.x - 10000 * (x - firstPoint.x),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y)
|
||||
),
|
||||
new Point(
|
||||
firstPoint.x + 10000 * (x - firstPoint.x),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y)
|
||||
)
|
||||
);
|
||||
angleLine.lineColor = ANGLE_GUIDES_COLOR;
|
||||
angleLine.lineDash = ANGLE_GUIDES_DASH;
|
||||
return angleLine;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {TO_DEGREES} from '../App.consts.ts';
|
||||
import {getAngleWithXAxis} from './get-angle-with-x-axis.ts';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TO_DEGREES } from '../App.consts.ts';
|
||||
import { getAngleWithXAxis } from './get-angle-with-x-axis.ts';
|
||||
|
||||
describe('getAngleWithXAxis', () => {
|
||||
it('should return 90 degrees in radians', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
|
||||
export function getAngleWithXAxis(start: Point, end: Point): number {
|
||||
const dx = end.x - start.x;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {type SnapPoint, SnapPointType} from '../App.types';
|
||||
import {pointDistance} from './distance-between-points';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { type SnapPoint, SnapPointType } from '../App.types';
|
||||
import { pointDistance } from './distance-between-points';
|
||||
|
||||
// /**
|
||||
// * Some points need to take priority over others when snapping to them. This multiplier is used to give a higher score to the points that should take priority
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {SNAP_ANGLE_DISTANCE} from '../App.consts';
|
||||
import {type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import type {LineEntity} from '../entities/LineEntity';
|
||||
import {findClosestEntity} from './find-closest-entity';
|
||||
import {getAngleGuideLines} from './get-angle-guide-lines';
|
||||
import {getClosestSnapPointWithinRadius} from './get-closest-snap-point';
|
||||
import {getIntersectionPoints} from './get-intersection-points';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { SNAP_ANGLE_DISTANCE } from '../App.consts';
|
||||
import { type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import type { LineEntity } from '../entities/LineEntity';
|
||||
import { findClosestEntity } from './find-closest-entity';
|
||||
import { getAngleGuideLines } from './get-angle-guide-lines';
|
||||
import { getClosestSnapPointWithinRadius } from './get-closest-snap-point';
|
||||
import { getIntersectionPoints } from './get-intersection-points';
|
||||
|
||||
/**
|
||||
* Gets the angle guides from the angle point to the mouse if the mouse is close to one of the angle steps and also returns the closest snap point
|
||||
|
||||
@@ -3,20 +3,20 @@ import type { Point } from '@flatten-js/core';
|
||||
|
||||
// TODO in the future we could optimize this by only calculating intersection points near the mouse
|
||||
export function getIntersectionPoints(entities: Entity[]): Point[] {
|
||||
const intersectionPoints: Point[] = [];
|
||||
const intersectionPoints: Point[] = [];
|
||||
|
||||
// Calculate all intersections between all entities
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity1 = entities[i];
|
||||
for (let j = i; j < entities.length; j++) {
|
||||
// intersections are symmetric, so we only need to calculate them in one direction (let j = i)
|
||||
// Calculate all intersections between all entities
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity1 = entities[i];
|
||||
for (let j = i; j < entities.length; j++) {
|
||||
// intersections are symmetric, so we only need to calculate them in one direction (let j = i)
|
||||
|
||||
if (i === j) continue; // Do not check for intersections with yourself
|
||||
if (i === j) continue; // Do not check for intersections with yourself
|
||||
|
||||
const entity2 = entities[j];
|
||||
intersectionPoints.push(...entity1.getIntersections(entity2));
|
||||
}
|
||||
}
|
||||
const entity2 = entities[j];
|
||||
intersectionPoints.push(...entity1.getIntersections(entity2));
|
||||
}
|
||||
}
|
||||
|
||||
return intersectionPoints;
|
||||
return intersectionPoints;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type Point, Vector} from '@flatten-js/core';
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
|
||||
export interface PointWithAngle {
|
||||
point: Point;
|
||||
angle: number;
|
||||
point: Point;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import {compact} from 'es-toolkit';
|
||||
import {saveAs} from 'file-saver';
|
||||
import type {Layer} from '../../App.types.ts';
|
||||
import type {Entity, JsonEntity} from '../../entities/Entity';
|
||||
import {getEntities, getLayers} from '../../state';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { saveAs } from 'file-saver';
|
||||
import type { Layer } from '../../App.types.ts';
|
||||
import type { Entity, JsonEntity } from '../../entities/Entity';
|
||||
import { getEntities, getLayers } from '../../state';
|
||||
|
||||
export async function exportEntitiesToJsonFile() {
|
||||
const json = await exportEntitiesAndLayersToJsonString();
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
|
||||
import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts';
|
||||
import { LOCAL_STORAGE_KEY } from '../../App.types.ts';
|
||||
import { exportEntitiesAndLayersToJsonString } from './export-entities-to-json.ts';
|
||||
|
||||
export async function exportEntitiesToLocalStorage() {
|
||||
const json = await exportEntitiesAndLayersToJsonString();
|
||||
|
||||
+34
-39
@@ -12,57 +12,52 @@ import { getEntities } from '../../state';
|
||||
* @param margin
|
||||
*/
|
||||
export function convertSvgToPngBlob(
|
||||
svgLines: string[],
|
||||
width: number,
|
||||
height: number,
|
||||
margin: number,
|
||||
svgLines: string[],
|
||||
width: number,
|
||||
height: number,
|
||||
margin: number
|
||||
): Promise<Blob> {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svg);
|
||||
const img = new Image();
|
||||
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svg);
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = width + margin * 2;
|
||||
canvas.height = height + margin * 2;
|
||||
img.onload = () => {
|
||||
canvas.width = width + margin * 2;
|
||||
canvas.height = height + margin * 2;
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, margin, margin);
|
||||
ctx.drawImage(img, margin, margin);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Could not convert canvas to blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Could not convert canvas to blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportEntitiesToPngFile() {
|
||||
const entities = getEntities();
|
||||
const entities = getEntities();
|
||||
|
||||
const svg = convertEntitiesToSvgString(entities);
|
||||
const pngDataBlob: Blob = await convertSvgToPngBlob(
|
||||
svg.svgLines,
|
||||
svg.width,
|
||||
svg.height,
|
||||
20,
|
||||
);
|
||||
const svg = convertEntitiesToSvgString(entities);
|
||||
const pngDataBlob: Blob = await convertSvgToPngBlob(svg.svgLines, svg.width, svg.height, 20);
|
||||
|
||||
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
|
||||
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
import {saveAs} from 'file-saver';
|
||||
import {SVG_MARGIN} from '../../App.consts';
|
||||
import {SvgDrawController} from '../../drawControllers/svg.drawController.ts';
|
||||
import type {Entity} from '../../entities/Entity';
|
||||
import {getEntities} from '../../state';
|
||||
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { SVG_MARGIN } from '../../App.consts';
|
||||
import { SvgDrawController } from '../../drawControllers/svg.drawController.ts';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { getEntities } from '../../state';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts';
|
||||
|
||||
export function convertEntitiesToSvgString(entities: Entity[]): {
|
||||
svgLines: string[];
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
|
||||
import {setActiveLayerId, setEntities, setLayers} from '../../state.ts';
|
||||
import {getNewLayer} from '../get-new-layer.ts';
|
||||
import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts';
|
||||
import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts";
|
||||
import { LOCAL_STORAGE_KEY } from '../../App.types.ts';
|
||||
import { setActiveLayerId, setEntities, setLayers } from '../../state.ts';
|
||||
import { getNewLayer } from '../get-new-layer.ts';
|
||||
import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts';
|
||||
import type { JsonDrawingFileDeserialized } from './export-entities-to-json.ts';
|
||||
|
||||
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
|
||||
const file = await getEntitiesAndLayersFromLocalStorage();
|
||||
|
||||
+11
-11
@@ -1,15 +1,15 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import {CircleEntity} from '../../entities/CircleEntity';
|
||||
import type {Entity} from '../../entities/Entity';
|
||||
import {LineEntity} from '../../entities/LineEntity';
|
||||
import {RectangleEntity} from '../../entities/RectangleEntity';
|
||||
import {getActiveLayerId, getEntities, setEntities} from '../../state';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CircleEntity } from '../../entities/CircleEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { RectangleEntity } from '../../entities/RectangleEntity';
|
||||
import { getActiveLayerId, getEntities, setEntities } from '../../state';
|
||||
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {type Node, parse, type RootNode} from 'svg-parser';
|
||||
import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts';
|
||||
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
|
||||
import {middle} from '../middle.ts';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { type Node, parse, type RootNode } from 'svg-parser';
|
||||
import { svgPathToSegments } from '../convert-svg-path-to-line-segments.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts';
|
||||
import { middle } from '../middle.ts';
|
||||
|
||||
function svgChildrenToEntities(root: RootNode): Entity[] {
|
||||
if (!root.children || !root.children?.[0]) {
|
||||
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
export interface SvgParseResult {
|
||||
type: string
|
||||
children: Children[]
|
||||
type: string;
|
||||
children: Children[];
|
||||
}
|
||||
|
||||
export interface Children {
|
||||
type: string
|
||||
tagName: string
|
||||
properties: Record<string, string>
|
||||
children: Children[]
|
||||
metadata?: string
|
||||
type: string;
|
||||
tagName: string;
|
||||
properties: Record<string, string>;
|
||||
children: Children[];
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
+9
-11
@@ -3,16 +3,14 @@
|
||||
* Load the image data
|
||||
* Convert it to a base64 string
|
||||
*/
|
||||
export function importImageFromFile(
|
||||
file: File | null | undefined,
|
||||
): Promise<HTMLImageElement> {
|
||||
return new Promise<HTMLImageElement>(resolve => {
|
||||
if (!file) return;
|
||||
export function importImageFromFile(file: File | null | undefined): Promise<HTMLImageElement> {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
if (!file) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {StartAndEndpointEntity} from '../App.types.ts';
|
||||
import {isClosedPolygon} from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StartAndEndpointEntity } from '../App.types.ts';
|
||||
import { isClosedPolygon } from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity
|
||||
|
||||
// Mock implementation for StartAndEndpointEntity
|
||||
class MockEntity implements StartAndEndpointEntity {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {Point} from "@flatten-js/core";
|
||||
import type {StartAndEndpointEntity} from "../App.types.ts";
|
||||
import {isPointEqual} from "./is-point-equal.ts";
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { StartAndEndpointEntity } from '../App.types.ts';
|
||||
import { isPointEqual } from './is-point-equal.ts';
|
||||
|
||||
/**
|
||||
* Check if entities form a closed loop polygon
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EPSILON } from '../App.consts';
|
||||
|
||||
export function isLengthEqual(length1: number, length2: number): boolean {
|
||||
return Math.abs(length1 - length2) < EPSILON;
|
||||
return Math.abs(length1 - length2) < EPSILON;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,5 @@ import type { Point } from '@flatten-js/core';
|
||||
import { EPSILON } from '../App.consts';
|
||||
|
||||
export function isPointEqual(point1: Point, point2: Point): boolean {
|
||||
return (
|
||||
Math.abs(point1.x - point2.x) < EPSILON &&
|
||||
Math.abs(point1.y - point2.y) < EPSILON
|
||||
);
|
||||
return Math.abs(point1.x - point2.x) < EPSILON && Math.abs(point1.y - point2.y) < EPSILON;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {KeyboardEvent} from "react";
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
export function keyboardHandler(clickHandler: () => void) {
|
||||
return (evt: KeyboardEvent) => {
|
||||
|
||||
@@ -2,60 +2,60 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mapNumberRange } from './map-number-range';
|
||||
|
||||
describe('mapNumberRange', () => {
|
||||
it('should map a value from the source range to the target range (normal range)', () => {
|
||||
expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50);
|
||||
expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0);
|
||||
expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100);
|
||||
});
|
||||
it('should map a value from the source range to the target range (normal range)', () => {
|
||||
expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50);
|
||||
expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0);
|
||||
expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('should map values outside the source range', () => {
|
||||
expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range
|
||||
expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range
|
||||
});
|
||||
it('should map values outside the source range', () => {
|
||||
expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range
|
||||
expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range
|
||||
});
|
||||
|
||||
it('should handle inverted source ranges', () => {
|
||||
// Source range is 10 to 0, mapping 5 should be halfway
|
||||
// Target range is 100 to 0, so halfway is 50
|
||||
expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50);
|
||||
it('should handle inverted source ranges', () => {
|
||||
// Source range is 10 to 0, mapping 5 should be halfway
|
||||
// Target range is 100 to 0, so halfway is 50
|
||||
expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50);
|
||||
|
||||
// Outside inverted range
|
||||
expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150);
|
||||
expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50);
|
||||
});
|
||||
// Outside inverted range
|
||||
expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150);
|
||||
expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50);
|
||||
});
|
||||
|
||||
it('should handle inverted target ranges', () => {
|
||||
// Normal source range, but inverted target
|
||||
expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50);
|
||||
expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100);
|
||||
expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0);
|
||||
});
|
||||
it('should handle inverted target ranges', () => {
|
||||
// Normal source range, but inverted target
|
||||
expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50);
|
||||
expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100);
|
||||
expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero-length source range', () => {
|
||||
// If the source range is a single point
|
||||
expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range
|
||||
expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range
|
||||
});
|
||||
it('should handle zero-length source range', () => {
|
||||
// If the source range is a single point
|
||||
expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range
|
||||
expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range
|
||||
});
|
||||
|
||||
it('should handle negative numbers and other ranges', () => {
|
||||
expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50);
|
||||
// Here: num = -10, source = [-20,0], target = [0,100]
|
||||
// Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50
|
||||
});
|
||||
it('should handle negative numbers and other ranges', () => {
|
||||
expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50);
|
||||
// Here: num = -10, source = [-20,0], target = [0,100]
|
||||
// Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50
|
||||
});
|
||||
|
||||
it('should handle floating point values', () => {
|
||||
expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input
|
||||
expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check
|
||||
});
|
||||
it('should handle floating point values', () => {
|
||||
expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input
|
||||
expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check
|
||||
});
|
||||
|
||||
it('should handle large ranges', () => {
|
||||
expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000);
|
||||
});
|
||||
it('should handle large ranges', () => {
|
||||
expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000);
|
||||
});
|
||||
|
||||
it('should handle screen coordinates to world correctly', () => {
|
||||
expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900);
|
||||
});
|
||||
it('should handle screen coordinates to world correctly', () => {
|
||||
expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900);
|
||||
});
|
||||
|
||||
it('should handle world coordinates to screen correctly', () => {
|
||||
expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100);
|
||||
});
|
||||
it('should handle world coordinates to screen correctly', () => {
|
||||
expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
* This is moslty used to convert screen space coordinates to world space coordinates and vice versa
|
||||
*/
|
||||
export function mapNumberRange(
|
||||
num: number,
|
||||
startSourceRange: number,
|
||||
endSourceRange: number,
|
||||
startTargetRange: number,
|
||||
endTargetRange: number,
|
||||
num: number,
|
||||
startSourceRange: number,
|
||||
endSourceRange: number,
|
||||
startTargetRange: number,
|
||||
endTargetRange: number
|
||||
): number {
|
||||
// Handle the case where source range has zero length
|
||||
if (startSourceRange === endSourceRange) {
|
||||
return startTargetRange;
|
||||
}
|
||||
// Handle the case where source range has zero length
|
||||
if (startSourceRange === endSourceRange) {
|
||||
return startTargetRange;
|
||||
}
|
||||
|
||||
return (
|
||||
startTargetRange +
|
||||
((num - startSourceRange) * (endTargetRange - startTargetRange)) /
|
||||
(endSourceRange - startSourceRange)
|
||||
);
|
||||
return (
|
||||
startTargetRange +
|
||||
((num - startSourceRange) * (endTargetRange - startTargetRange)) /
|
||||
(endSourceRange - startSourceRange)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {LineEntity} from "../entities/LineEntity.ts";
|
||||
import type { LineEntity } from '../entities/LineEntity.ts';
|
||||
|
||||
export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) {
|
||||
const mirrorAngle = mirrorAxis.getAngle();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {describe, expect, it} from "vitest";
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {mirrorPointOverAxis} from './mirror-point-over-axis';
|
||||
import {LineEntity} from "../entities/LineEntity.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { mirrorPointOverAxis } from './mirror-point-over-axis';
|
||||
import { LineEntity } from '../entities/LineEntity.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
describe("mirrorPointOverAxis", () => {
|
||||
describe('mirrorPointOverAxis', () => {
|
||||
it('should mirror if the axis is horizontal', () => {
|
||||
const point = new Point(100, 100);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50));
|
||||
@@ -13,7 +13,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirroredPoint.y).toBe(0);
|
||||
});
|
||||
|
||||
it("mirrors a point over a vertical axis", () => {
|
||||
it('mirrors a point over a vertical axis', () => {
|
||||
const point = new Point(3, 4);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -21,7 +21,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it("mirrors a point over the diagonal line y = x", () => {
|
||||
it('mirrors a point over the diagonal line y = x', () => {
|
||||
const point = new Point(3, 4);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -30,7 +30,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it("returns the same point if the point lies on the mirror axis", () => {
|
||||
it('returns the same point if the point lies on the mirror axis', () => {
|
||||
const point = new Point(1, 1);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -38,7 +38,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("returns the original point when mirrored twice", () => {
|
||||
it('returns the original point when mirrored twice', () => {
|
||||
const point = new Point(5, 7);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Point, type Segment } from '@flatten-js/core';
|
||||
import type {LineEntity} from "../entities/LineEntity.ts";
|
||||
import type { LineEntity } from '../entities/LineEntity.ts';
|
||||
|
||||
export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) {
|
||||
const mirrorAxisSegment = mirrorAxis.getShape() as Segment;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Polygon, Segment} from '@flatten-js/core';
|
||||
import type { Polygon, Segment } from '@flatten-js/core';
|
||||
|
||||
export function polygonToSegments(polygon: Polygon): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
|
||||
export function rotatePoint(
|
||||
point: Point,
|
||||
rotateOrigin: Point,
|
||||
angle: number,
|
||||
): Point {
|
||||
const vector = new Vector(rotateOrigin, point);
|
||||
const rotatedVector = vector.rotate(angle);
|
||||
return new Point(rotatedVector.x, rotatedVector.y);
|
||||
export function rotatePoint(point: Point, rotateOrigin: Point, angle: number): Point {
|
||||
const vector = new Vector(rotateOrigin, point);
|
||||
const rotatedVector = vector.rotate(angle);
|
||||
return new Point(rotatedVector.x, rotatedVector.y);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
|
||||
export function scalePoint(
|
||||
point: Point,
|
||||
scaleOrigin: Point,
|
||||
scaleFactor: number,
|
||||
): Point {
|
||||
const vector = new Vector(scaleOrigin, point);
|
||||
const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1);
|
||||
return new Point(point.x + scaledVector.x, point.y + scaledVector.y);
|
||||
export function scalePoint(point: Point, scaleOrigin: Point, scaleFactor: number): Point {
|
||||
const vector = new Vector(scaleOrigin, point);
|
||||
const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1);
|
||||
return new Point(point.x + scaledVector.x, point.y + scaledVector.y);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import {
|
||||
getGridEnabled,
|
||||
getHighlightedEntityIds,
|
||||
setHighlightedEntityIds,
|
||||
} from '../state';
|
||||
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
|
||||
import { drawEntities } from './draw-functions';
|
||||
import { getSceneVersion } from './scene-version';
|
||||
import { queryEntitiesInBox } from './spatial-index';
|
||||
@@ -130,7 +126,11 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
|
||||
|
||||
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
|
||||
// bitmap would drag the grid along — always re-render while grid is on.
|
||||
if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
|
||||
if (
|
||||
paramsChanged ||
|
||||
getGridEnabled() ||
|
||||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
|
||||
) {
|
||||
rebuildScene(drawController);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,24 +10,22 @@ import { ArcEntity } from '../entities/ArcEntity';
|
||||
* @param startPoint
|
||||
*/
|
||||
export function sortPointsOnArc(
|
||||
pointsOnArc: Point[],
|
||||
centerPoint: Point,
|
||||
startPoint: Point,
|
||||
pointsOnArc: Point[],
|
||||
centerPoint: Point,
|
||||
startPoint: Point
|
||||
): Point[] {
|
||||
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
|
||||
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
|
||||
|
||||
// Angles calculated from start point (0 degrees) and up
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => {
|
||||
return {
|
||||
point,
|
||||
// Ensure all angles are between 0 (start point) and < 2PI,
|
||||
// so we can sort them starting at the start point angle
|
||||
angle:
|
||||
(new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) %
|
||||
(2 * Math.PI),
|
||||
};
|
||||
});
|
||||
return sortBy(pointsWithAngles, [
|
||||
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
|
||||
]).map(pointsWithAngle => pointsWithAngle.point);
|
||||
// Angles calculated from start point (0 degrees) and up
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map((point) => {
|
||||
return {
|
||||
point,
|
||||
// Ensure all angles are between 0 (start point) and < 2PI,
|
||||
// so we can sort them starting at the start point angle
|
||||
angle: (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % (2 * Math.PI),
|
||||
};
|
||||
});
|
||||
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
|
||||
(pointsWithAngle) => pointsWithAngle.point
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,17 +7,14 @@ import type { PointWithAngle } from './helpers.types';
|
||||
* @param pointsOnCircle
|
||||
* @param centerPoint
|
||||
*/
|
||||
export function sortPointsOnCircle(
|
||||
pointsOnCircle: Point[],
|
||||
centerPoint: Point,
|
||||
): Point[] {
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => {
|
||||
return {
|
||||
point,
|
||||
angle: new Line(centerPoint, point).slope,
|
||||
};
|
||||
});
|
||||
return sortBy(pointsWithAngles, [
|
||||
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
|
||||
]).map(pointsWithAngle => pointsWithAngle.point);
|
||||
export function sortPointsOnCircle(pointsOnCircle: Point[], centerPoint: Point): Point[] {
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map((point) => {
|
||||
return {
|
||||
point,
|
||||
angle: new Line(centerPoint, point).slope,
|
||||
};
|
||||
});
|
||||
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
|
||||
(pointsWithAngle) => pointsWithAngle.point
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
export function times<T>(
|
||||
num: number,
|
||||
iterateeFunc: (i: number) => T = (i: number) => i as T,
|
||||
): T[] {
|
||||
let i = 0;
|
||||
const items = [];
|
||||
while (i < num) {
|
||||
items.push(iterateeFunc(i));
|
||||
i++;
|
||||
}
|
||||
return items;
|
||||
export function times<T>(num: number, iterateeFunc: (i: number) => T = (i: number) => i as T): T[] {
|
||||
let i = 0;
|
||||
const items = [];
|
||||
while (i < num) {
|
||||
items.push(iterateeFunc(i));
|
||||
i++;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -7,71 +7,64 @@ import { HOVERED_SNAP_POINT_TIME, MAX_MARKED_SNAP_POINTS } from '../App.consts';
|
||||
* So we can show extra angle guides for the ones that are marked
|
||||
*/
|
||||
export function trackHoveredSnapPoint(
|
||||
worldSnapPoint: SnapPoint | null,
|
||||
worldHoveredSnapPoints: HoverPoint[],
|
||||
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
|
||||
maxHoverDistance: number,
|
||||
elapsedTime: number,
|
||||
worldSnapPoint: SnapPoint | null,
|
||||
worldHoveredSnapPoints: HoverPoint[],
|
||||
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
|
||||
maxHoverDistance: number,
|
||||
elapsedTime: number
|
||||
) {
|
||||
if (!worldSnapPoint) {
|
||||
return;
|
||||
}
|
||||
if (!worldSnapPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastHoveredPoint = worldHoveredSnapPoints.at(-1);
|
||||
let newHoverSnapPoints: HoverPoint[];
|
||||
const lastHoveredPoint = worldHoveredSnapPoints.at(-1);
|
||||
let newHoverSnapPoints: HoverPoint[];
|
||||
|
||||
// Angle guide points should never be marked
|
||||
if (lastHoveredPoint) {
|
||||
if (
|
||||
pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) <
|
||||
maxHoverDistance
|
||||
) {
|
||||
// Last hovered snap point is still the current closest snap point
|
||||
// Increase the hover time
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
|
||||
{
|
||||
...lastHoveredPoint,
|
||||
milliSecondsHovered:
|
||||
lastHoveredPoint.milliSecondsHovered + elapsedTime,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// The closest snap point has changed
|
||||
// Check if the last snap point was hovered for long enough to be considered a marked snap point
|
||||
if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) {
|
||||
// Append the new point to the list
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints,
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Replace the last point with the new point
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No snap points were hovered before
|
||||
newHoverSnapPoints = [
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
// Angle guide points should never be marked
|
||||
if (lastHoveredPoint) {
|
||||
if (pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) < maxHoverDistance) {
|
||||
// Last hovered snap point is still the current closest snap point
|
||||
// Increase the hover time
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
|
||||
{
|
||||
...lastHoveredPoint,
|
||||
milliSecondsHovered: lastHoveredPoint.milliSecondsHovered + elapsedTime,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// The closest snap point has changed
|
||||
// Check if the last snap point was hovered for long enough to be considered a marked snap point
|
||||
if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) {
|
||||
// Append the new point to the list
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints,
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Replace the last point with the new point
|
||||
newHoverSnapPoints = [
|
||||
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No snap points were hovered before
|
||||
newHoverSnapPoints = [
|
||||
{
|
||||
snapPoint: worldSnapPoint,
|
||||
milliSecondsHovered: elapsedTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(
|
||||
0,
|
||||
MAX_MARKED_SNAP_POINTS,
|
||||
);
|
||||
setHoveredSnapPoints(newHoverSnapPointsTruncated);
|
||||
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(0, MAX_MARKED_SNAP_POINTS);
|
||||
setHoveredSnapPoints(newHoverSnapPointsTruncated);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
// 3 => 0
|
||||
// 4 => 1
|
||||
export function wrapModule(index: number, length: number) {
|
||||
return (index + length) % length;
|
||||
return (index + length) % length;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
/**
|
||||
* AlignBottom tool state machine
|
||||
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
|
||||
* When the user presses enter, the selected entities are bottom aligned
|
||||
*/
|
||||
export const alignBottomToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin);
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import {middle} from "../helpers/middle.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
import { middle } from '../helpers/middle.ts';
|
||||
|
||||
/**
|
||||
* AlignCenterHorizontal tool state machine
|
||||
@@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts";
|
||||
* When the user presses enter, the selected entities are center horizontal aligned
|
||||
*/
|
||||
export const alignCenterHorizontalToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX);
|
||||
const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax);
|
||||
entity.move(centerBoundingBoxX - centerEntityX, 0);
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX);
|
||||
const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax);
|
||||
entity.move(centerBoundingBoxX - centerEntityX, 0);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
/**
|
||||
* AlignLeft tool state machine
|
||||
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
|
||||
* When the user presses enter, the selected entities are left aligned
|
||||
*/
|
||||
export const alignLeftToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0);
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import {middle} from "../helpers/middle.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
import { middle } from '../helpers/middle.ts';
|
||||
|
||||
/**
|
||||
* AlignCenterVertical tool state machine
|
||||
@@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts";
|
||||
* When the user presses enter, the selected entities are center vertical aligned
|
||||
*/
|
||||
export const alignCenterVerticalToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY);
|
||||
const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax);
|
||||
entity.move(0, centerBoundingBoxY - centerEntityY);
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
const entityBoundingBox = entity.getBoundingBox();
|
||||
const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY);
|
||||
const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax);
|
||||
entity.move(0, centerBoundingBoxY - centerEntityY);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
/**
|
||||
* AlignRight tool state machine
|
||||
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
|
||||
* When the user presses enter, the selected entities are right aligned
|
||||
*/
|
||||
export const alignRightToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0);
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {assign, type MachineContext, sendTo} from 'xstate';
|
||||
import type {Entity} from '../entities/Entity.ts';
|
||||
import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { assign, type MachineContext, sendTo } from 'xstate';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
import {
|
||||
type BoundingBox,
|
||||
getBoundingBoxOfMultipleEntities,
|
||||
} from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import {
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
@@ -9,9 +12,15 @@ import {
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state.ts';
|
||||
import type {Tool} from '../tools.ts';
|
||||
import {selectToolStateMachine} from './select-tool.ts';
|
||||
import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts';
|
||||
import type { Tool } from '../tools.ts';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import type {
|
||||
DrawEvent,
|
||||
KeyboardEnterEvent,
|
||||
MouseClickEvent,
|
||||
StateEvent,
|
||||
ToolContext,
|
||||
} from './tool.types.ts';
|
||||
|
||||
export interface AlignContext extends ToolContext {}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Tool} from '../tools';
|
||||
import {createMachine} from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
/**
|
||||
* AlignTop tool state machine
|
||||
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
|
||||
* When the user presses enter, the selected entities are top aligned
|
||||
*/
|
||||
export const alignTopToolStateMachine = createMachine(
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY));
|
||||
})
|
||||
GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP),
|
||||
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
|
||||
entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -97,7 +97,7 @@ export const dimAngularToolStateMachine = createSequenceTool({
|
||||
|
||||
const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x);
|
||||
const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x);
|
||||
const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI));
|
||||
const sweep = (endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI);
|
||||
const midAngle = startAngle + sweep / 2;
|
||||
const degrees = (sweep * 180) / Math.PI;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { 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,
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {CopyAction} from './copy-tool.ts';
|
||||
import {selectToolStateMachine} from './select-tool.ts';
|
||||
import { Tool } from '../tools';
|
||||
import { CopyAction } from './copy-tool.ts';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import type {
|
||||
AbsolutePointInputEvent,
|
||||
DrawEvent,
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} 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 {moveEntities} from './move-tool.helpers';
|
||||
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 { moveEntities } from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
copiedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
copiedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
|
||||
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',
|
||||
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',
|
||||
}
|
||||
|
||||
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',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,242 +55,227 @@ export enum CopyAction {
|
||||
* When the user clicks again, the end point is selected and the entities are copied to the new location
|
||||
*/
|
||||
export const copyToolStateMachine = createMachine(
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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();
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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();
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
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 => entity.clone()),
|
||||
),
|
||||
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',
|
||||
);
|
||||
}
|
||||
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) => entity.clone())),
|
||||
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();
|
||||
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 =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
);
|
||||
// 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) => entity.clone());
|
||||
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',
|
||||
);
|
||||
}
|
||||
// // 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 =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
);
|
||||
// Copy the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// Switch the copied entities back from the ghost helper entities to the real entities
|
||||
addEntities([...context.originalSelectedEntities, ...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,
|
||||
},
|
||||
},
|
||||
// Switch the copied entities back from the ghost helper entities to the real entities
|
||||
addEntities([...context.originalSelectedEntities, ...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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} 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 {
|
||||
arcEntity,
|
||||
lineEntity,
|
||||
pointEntity,
|
||||
polyLineEntity,
|
||||
styled,
|
||||
} from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const plineToolStateMachine = createSequenceTool({
|
||||
@@ -138,7 +144,11 @@ export const donutToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
|
||||
function donutEntities(
|
||||
innerDiameter: number,
|
||||
outerDiameter: number,
|
||||
center: Parameters<typeof pointEntity>[0]
|
||||
): Entity[] {
|
||||
const circles: Entity[] = [];
|
||||
for (const diameter of [innerDiameter, outerDiameter]) {
|
||||
if (diameter > 0) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
|
||||
import { toast } from 'react-toastify';
|
||||
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import {
|
||||
dividePoints,
|
||||
measurePoints,
|
||||
sampleEntityPoints,
|
||||
} from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pointEntity } from '../factories/entity-factory';
|
||||
|
||||
@@ -57,9 +57,7 @@ export const hatchToolStateMachine = createSequenceTool({
|
||||
export const gradientToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GRADIENT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
|
||||
],
|
||||
steps: [{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
@@ -80,9 +78,7 @@ export const gradientToolStateMachine = createSequenceTool({
|
||||
export const boundaryToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BOUNDARY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
|
||||
],
|
||||
steps: [{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {type Circle, Point, type Segment} from '@flatten-js/core';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {ArcEntity} from '../entities/ArcEntity';
|
||||
import type {CircleEntity} from '../entities/CircleEntity';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import type {LineEntity} from '../entities/LineEntity';
|
||||
import {findNeighboringPointsOnArc} from '../helpers/find-neighboring-points-on-arc';
|
||||
import {findNeighboringPointsOnCircle} from '../helpers/find-neighboring-points-on-circle';
|
||||
import {findNeighboringPointsOnLine} from '../helpers/find-neighboring-points-on-line';
|
||||
import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts';
|
||||
import {isPointEqual} from '../helpers/is-point-equal';
|
||||
import {addEntities, deleteEntities, getActiveLayerId} from '../state';
|
||||
import { type Circle, Point, type Segment } from '@flatten-js/core';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { ArcEntity } from '../entities/ArcEntity';
|
||||
import type { CircleEntity } from '../entities/CircleEntity';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import type { LineEntity } from '../entities/LineEntity';
|
||||
import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc';
|
||||
import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle';
|
||||
import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal';
|
||||
import { addEntities, deleteEntities, getActiveLayerId } from '../state';
|
||||
|
||||
export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] {
|
||||
// TODO see if we need to make this list unique
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {type Arc, Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {TO_DEGREES, TO_RADIANS} from '../App.consts.ts';
|
||||
import type {ArcEntity} from '../entities/ArcEntity.ts';
|
||||
import {CircleEntity} from '../entities/CircleEntity.ts';
|
||||
import {EntityName} from '../entities/Entity.ts';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity.ts';
|
||||
import {getEntities, setEntities} from '../state.ts';
|
||||
import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts';
|
||||
import {handleMouseClick} from './eraser-tool.ts';
|
||||
import { type Arc, Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TO_DEGREES, TO_RADIANS } from '../App.consts.ts';
|
||||
import type { ArcEntity } from '../entities/ArcEntity.ts';
|
||||
import { CircleEntity } from '../entities/CircleEntity.ts';
|
||||
import { EntityName } from '../entities/Entity.ts';
|
||||
import { RectangleEntity } from '../entities/RectangleEntity.ts';
|
||||
import { getEntities, setEntities } from '../state.ts';
|
||||
import { eraseCircleSegment, getAllIntersectionPoints } from './eraser-tool.helpers.ts';
|
||||
import { handleMouseClick } from './eraser-tool.ts';
|
||||
|
||||
describe('erase-tool', () => {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Box, type Point} from '@flatten-js/core';
|
||||
import { Box, type Point } from '@flatten-js/core';
|
||||
|
||||
export function getContainRectangleInsideRectangle(
|
||||
imageWidth: number,
|
||||
|
||||
@@ -1,245 +1,250 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setAngleGuideEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setAngleGuideEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {Actor, assign, createMachine} from 'xstate';
|
||||
import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types';
|
||||
import {ImageEntity} from '../entities/ImageEntity';
|
||||
import {getContainRectangleInsideRectangle} from './image-import-tool.helpers';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import {boxToPolygon, twoPointBoxToPolygon} from '../helpers/box-to-polygon';
|
||||
import {isPointEqual} from '../helpers/is-point-equal.ts';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
import { Tool } from '../tools';
|
||||
import { Actor, assign, createMachine } from 'xstate';
|
||||
import {
|
||||
ActorEvent,
|
||||
type DrawEvent,
|
||||
type FileSelectedEvent,
|
||||
type MouseClickEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import { ImageEntity } from '../entities/ImageEntity';
|
||||
import { getContainRectangleInsideRectangle } from './image-import-tool.helpers';
|
||||
import { RectangleEntity } from '../entities/RectangleEntity';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import { boxToPolygon, twoPointBoxToPolygon } from '../helpers/box-to-polygon';
|
||||
import { isPointEqual } from '../helpers/is-point-equal.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface ImageImportContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
imageElement: HTMLImageElement | null;
|
||||
startPoint: Point | null;
|
||||
imageElement: HTMLImageElement | null;
|
||||
}
|
||||
|
||||
export enum ImageImportState {
|
||||
INIT = 'INIT',
|
||||
WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
INIT = 'INIT',
|
||||
WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum ImageImportAction {
|
||||
INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL',
|
||||
STORE_IMAGE_DATA = 'STORE_IMAGE_DATA',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT',
|
||||
DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL',
|
||||
STORE_IMAGE_DATA = 'STORE_IMAGE_DATA',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT',
|
||||
DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
}
|
||||
|
||||
export const imageImportToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ImageImportContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.IMAGE_IMPORT,
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
},
|
||||
initial: ImageImportState.INIT,
|
||||
states: {
|
||||
[ImageImportState.INIT]: {
|
||||
description: 'Initializing the imageImport tool',
|
||||
always: {
|
||||
actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
target: ImageImportState.WAIT_FOR_IMAGE_DATA,
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAIT_FOR_IMAGE_DATA]: {
|
||||
description: 'Select an image file to import',
|
||||
meta: {
|
||||
instructions: 'Select an image file to import',
|
||||
},
|
||||
on: {
|
||||
[ActorEvent.FILE_SELECTED]: {
|
||||
actions: ImageImportAction.STORE_IMAGE_DATA,
|
||||
target: ImageImportState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => {
|
||||
return {
|
||||
imageElement: (event as FileSelectedEvent).image,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
...context,
|
||||
startPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (
|
||||
isPointEqual(
|
||||
context.startPoint,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
)
|
||||
) {
|
||||
return; // Can't draw an image that is 0 pixels wide
|
||||
}
|
||||
const endPoint = getPointFromEvent(
|
||||
context.startPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
endPoint,
|
||||
);
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
containRectangle.low,
|
||||
containRectangle.high,
|
||||
0,
|
||||
);
|
||||
const draggedRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
twoPointBoxToPolygon(context.startPoint, endPoint),
|
||||
);
|
||||
setGhostHelperEntities([activeImage]);
|
||||
setAngleGuideEntities([draggedRectangle]);
|
||||
},
|
||||
[ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT',
|
||||
);
|
||||
}
|
||||
{
|
||||
types: {} as {
|
||||
context: ImageImportContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.IMAGE_IMPORT,
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
},
|
||||
initial: ImageImportState.INIT,
|
||||
states: {
|
||||
[ImageImportState.INIT]: {
|
||||
description: 'Initializing the imageImport tool',
|
||||
always: {
|
||||
actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
target: ImageImportState.WAIT_FOR_IMAGE_DATA,
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAIT_FOR_IMAGE_DATA]: {
|
||||
description: 'Select an image file to import',
|
||||
meta: {
|
||||
instructions: 'Select an image file to import',
|
||||
},
|
||||
on: {
|
||||
[ActorEvent.FILE_SELECTED]: {
|
||||
actions: ImageImportAction.STORE_IMAGE_DATA,
|
||||
target: ImageImportState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: ImageImportAction.RECORD_START_POINT,
|
||||
target: ImageImportState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ImageImportState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the imageImport',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the imageImport',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [
|
||||
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
|
||||
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
|
||||
ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
],
|
||||
},
|
||||
ESC: {
|
||||
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
imageElement: null,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => {
|
||||
return {
|
||||
imageElement: (event as FileSelectedEvent).image,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
...context,
|
||||
startPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
if (
|
||||
isPointEqual(
|
||||
context.startPoint,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
)
|
||||
) {
|
||||
return; // Can't draw an image that is 0 pixels wide
|
||||
}
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
endPoint
|
||||
);
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
containRectangle.low,
|
||||
containRectangle.high,
|
||||
0
|
||||
);
|
||||
const draggedRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
twoPointBoxToPolygon(context.startPoint, endPoint)
|
||||
);
|
||||
setGhostHelperEntities([activeImage]);
|
||||
setAngleGuideEntities([draggedRectangle]);
|
||||
},
|
||||
[ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
if (!context.imageElement) {
|
||||
throw new Error(
|
||||
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation
|
||||
);
|
||||
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
boxToPolygon(containRectangle),
|
||||
);
|
||||
addEntities([activeImage], true);
|
||||
},
|
||||
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
},
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
boxToPolygon(containRectangle)
|
||||
);
|
||||
addEntities([activeImage], true);
|
||||
},
|
||||
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -50,14 +50,7 @@ export const chamferToolStateMachine = createSequenceTool({
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(
|
||||
first,
|
||||
input.pick(2),
|
||||
second,
|
||||
input.pick(3),
|
||||
input.number(0),
|
||||
input.number(1)
|
||||
)
|
||||
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,14 +80,8 @@ export function filletLines(
|
||||
}
|
||||
|
||||
const tangentDistance = radius / Math.tan(angle / 2);
|
||||
const tangentA = new Point(
|
||||
corner.x + ua.x * tangentDistance,
|
||||
corner.y + ua.y * tangentDistance
|
||||
);
|
||||
const tangentB = new Point(
|
||||
corner.x + ub.x * tangentDistance,
|
||||
corner.y + ub.y * tangentDistance
|
||||
);
|
||||
const tangentA = new Point(corner.x + ua.x * tangentDistance, corner.y + ua.y * tangentDistance);
|
||||
const tangentB = new Point(corner.x + ub.x * tangentDistance, corner.y + ub.y * tangentDistance);
|
||||
|
||||
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
|
||||
const centerDistance = radius / Math.sin(angle / 2);
|
||||
|
||||
@@ -129,7 +129,11 @@ export const lengthenToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LENGTHEN,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
|
||||
{
|
||||
kind: 'number',
|
||||
instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.',
|
||||
defaultValue: 10,
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
|
||||
/**
|
||||
* Move entities by the difference between the start and end points
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} 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 {moveEntities} from './move-tool.helpers';
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
|
||||
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 { moveEntities } from './move-tool.helpers';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
|
||||
export interface MoveContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
movedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
movedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
|
||||
export enum MoveState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT',
|
||||
WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT',
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT',
|
||||
WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT',
|
||||
}
|
||||
|
||||
export enum MoveAction {
|
||||
INIT_MOVE_TOOL = 'INIT_MOVE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE',
|
||||
DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES',
|
||||
MOVE_SELECTION = 'MOVE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
INIT_MOVE_TOOL = 'INIT_MOVE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE',
|
||||
DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES',
|
||||
MOVE_SELECTION = 'MOVE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,243 +56,230 @@ export enum MoveAction {
|
||||
* When the user clicks again, the end point is selected and the entities are moved to the new location
|
||||
*/
|
||||
export const moveToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MoveContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.MOVE,
|
||||
},
|
||||
initial: MoveState.INIT,
|
||||
states: {
|
||||
[MoveState.INIT]: {
|
||||
description: 'Initializing the move tool',
|
||||
always: {
|
||||
actions: MoveAction.INIT_MOVE_TOOL,
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[MoveState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_START_MOVE_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[MoveState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to move',
|
||||
meta: {
|
||||
instructions: 'Select what you want to move, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheMoveTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
|
||||
description: 'Select the start of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the start of the move line',
|
||||
},
|
||||
always: {
|
||||
actions: MoveAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
MoveAction.RECORD_START_POINT,
|
||||
MoveAction.COPY_SELECTION_BEFORE_MOVE,
|
||||
],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.DESELECT_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
|
||||
description: 'Select the end of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the move line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MoveAction.INIT_MOVE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
{
|
||||
types: {} as {
|
||||
context: MoveContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.MOVE,
|
||||
},
|
||||
initial: MoveState.INIT,
|
||||
states: {
|
||||
[MoveState.INIT]: {
|
||||
description: 'Initializing the move tool',
|
||||
always: {
|
||||
actions: MoveAction.INIT_MOVE_TOOL,
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[MoveState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_START_MOVE_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[MoveState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to move',
|
||||
meta: {
|
||||
instructions: 'Select what you want to move, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheMoveTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: MoveState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
|
||||
description: 'Select the start of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the start of the move line',
|
||||
},
|
||||
always: {
|
||||
actions: MoveAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.DESELECT_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
|
||||
description: 'Select the end of the move line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the move line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MoveAction.INIT_MOVE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
movedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Move 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);
|
||||
// Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
// Move 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);
|
||||
// Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
|
||||
// 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 moved and the original entities also are used for snap points / angle guides
|
||||
// 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 moved and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
movedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[MOVE] Calling draw temp move line without a start point',
|
||||
);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
movedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[MOVE] Calling draw temp move line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Move the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
|
||||
const movedEntities = context.originalSelectedEntities.map(entity =>
|
||||
entity.clone(),
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
);
|
||||
// Move the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeMoveLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp,
|
||||
);
|
||||
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeMoveLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeMoveLine, ...movedEntities]);
|
||||
},
|
||||
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[MOVE] Calling move selection without a start point',
|
||||
);
|
||||
}
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeMoveLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp
|
||||
);
|
||||
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeMoveLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeMoveLine, ...movedEntities]);
|
||||
},
|
||||
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[MOVE] Calling move selection without a start point');
|
||||
}
|
||||
|
||||
// Move the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
moveEntities(
|
||||
context.originalSelectedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
);
|
||||
// Move the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
moveEntities(
|
||||
context.originalSelectedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// Switch the moved entities back from the ghost helper entities to the real entities
|
||||
addEntities(context.originalSelectedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[MoveAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.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,
|
||||
},
|
||||
},
|
||||
// Switch the moved entities back from the ghost helper entities to the real entities
|
||||
addEntities(context.originalSelectedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[MoveAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[MoveAction.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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import {assign, createMachine, sendTo} from 'xstate';
|
||||
import {PolyLineEntity} from '../entities/PolyLineEntity.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { PolyLineEntity } from '../entities/PolyLineEntity.ts';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getNotSelectedEntities,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import type {StateEvent, ToolContext} from './tool.types';
|
||||
import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { StateEvent, ToolContext } from './tool.types';
|
||||
|
||||
export interface PeditContext extends ToolContext {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Line, type Point} from '@flatten-js/core';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import { Line, type Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
|
||||
/**
|
||||
* Rotate entities round a base point by a certain angle
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} 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 {rotateEntities} from './rotate-tool.helpers';
|
||||
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 { rotateEntities } from './rotate-tool.helpers';
|
||||
|
||||
export interface RotateContext extends ToolContext {
|
||||
rotationOrigin: Point | null;
|
||||
angleStartPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
rotationOrigin: Point | null;
|
||||
angleStartPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
}
|
||||
|
||||
export enum RotateState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN',
|
||||
WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT',
|
||||
WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT',
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN',
|
||||
WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT',
|
||||
WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT',
|
||||
}
|
||||
|
||||
export enum RotateAction {
|
||||
INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN',
|
||||
RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT',
|
||||
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
|
||||
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
|
||||
ROTATE_SELECTION = 'ROTATE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN',
|
||||
RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT',
|
||||
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
|
||||
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
|
||||
ROTATE_SELECTION = 'ROTATE_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,271 +55,251 @@ export enum RotateAction {
|
||||
* When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin
|
||||
*/
|
||||
export const rotateToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: RotateContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.ROTATE,
|
||||
},
|
||||
initial: RotateState.INIT,
|
||||
states: {
|
||||
[RotateState.INIT]: {
|
||||
description: 'Initializing the rotate tool',
|
||||
always: {
|
||||
actions: RotateAction.INIT_ROTATE_TOOL,
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[RotateState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_ROTATION_ORIGIN,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[RotateState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to rotate',
|
||||
meta: {
|
||||
instructions: 'Select what you want to rotate, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheRotateTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(({ context }) => {
|
||||
return {
|
||||
...context,
|
||||
};
|
||||
}),
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
RotateAction.INIT_ROTATE_TOOL,
|
||||
],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
|
||||
description: 'Select the origin of the rotate operation',
|
||||
meta: {
|
||||
instructions: 'Select the origin of the rotate operation',
|
||||
},
|
||||
always: {
|
||||
actions: RotateAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
|
||||
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.DESELECT_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
|
||||
description: 'Select the end of the base rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the base rotate line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.RECORD_ROTATION_ANGLE_START_POINT,
|
||||
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
|
||||
description: 'Select the end of the rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the rotate line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.ROTATE_SELECTION,
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RotateAction.INIT_ROTATE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RotateAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
setAngleGuideOriginPoint(
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
return {
|
||||
...context,
|
||||
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(
|
||||
({ context }): RotateContext => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
{
|
||||
types: {} as {
|
||||
context: RotateContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.ROTATE,
|
||||
},
|
||||
initial: RotateState.INIT,
|
||||
states: {
|
||||
[RotateState.INIT]: {
|
||||
description: 'Initializing the rotate tool',
|
||||
always: {
|
||||
actions: RotateAction.INIT_ROTATE_TOOL,
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[RotateState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_ROTATION_ORIGIN,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[RotateState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to rotate',
|
||||
meta: {
|
||||
instructions: 'Select what you want to rotate, then ENTER',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheRotateTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(({ context }) => {
|
||||
return {
|
||||
...context,
|
||||
};
|
||||
}),
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [RotateAction.DESELECT_ENTITIES, RotateAction.INIT_ROTATE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
|
||||
description: 'Select the origin of the rotate operation',
|
||||
meta: {
|
||||
instructions: 'Select the origin of the rotate operation',
|
||||
},
|
||||
always: {
|
||||
actions: RotateAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
|
||||
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.DESELECT_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
|
||||
description: 'Select the end of the base rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the base rotate line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.RECORD_ROTATION_ANGLE_START_POINT,
|
||||
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
|
||||
description: 'Select the end of the rotate line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the rotate line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [RotateAction.ROTATE_SELECTION, RotateAction.DESELECT_ENTITIES],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RotateAction.INIT_ROTATE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RotateAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
return {
|
||||
...context,
|
||||
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}
|
||||
),
|
||||
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(({ context }): RotateContext => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
|
||||
// Rotate 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);
|
||||
// Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
// Rotate 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);
|
||||
// Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
|
||||
// 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 rotated and the original entities also are used for snap points / angle guides
|
||||
// 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 rotated and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point',
|
||||
);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
|
||||
};
|
||||
}),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point'
|
||||
);
|
||||
}
|
||||
|
||||
const angleEndpoint = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
const angleEndpoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
);
|
||||
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint
|
||||
);
|
||||
|
||||
setGhostHelperEntities(rotatedEntities);
|
||||
},
|
||||
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling rotate selection without some rotate vector endpoints',
|
||||
);
|
||||
}
|
||||
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
setGhostHelperEntities(rotatedEntities);
|
||||
},
|
||||
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error('[ROTATE] Calling rotate selection without some rotate vector endpoints');
|
||||
}
|
||||
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
// Rotate the entities one final time
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
);
|
||||
// Rotate the entities one final time
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint
|
||||
);
|
||||
|
||||
// Switch the rotated entities back from the ghost helper entities to the real entities
|
||||
addEntities(rotatedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(
|
||||
({ context }): RotateContext => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
},
|
||||
),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
// Switch the rotated entities back from the ghost helper entities to the real entities
|
||||
addEntities(rotatedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {pointDistance} from '../helpers/distance-between-points';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
|
||||
/**
|
||||
* Scale entities by base vector to destination scale vector
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user