From 4cb9b1593913a36b7044986bd94e632574f4438a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 07:08:24 +0900 Subject: [PATCH] =?UTF-8?q?style:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=8F=AC=EB=A7=B7=ED=84=B0=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=A0=81=EC=9A=A9=20(prettier=C2=B7biome=C2=B7ruff?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일마다 포맷 폭이 달라(≈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) --- .prettierignore | 10 + B03_FileInput/B03_FileInput_Api_Fetch.ts | 112 ++-- B03_FileInput/B03_FileInput_UI_Page.ts | 168 ++---- B03_FileInput/B03_FileInput_UI_Style.css | 8 +- B03_FileInput/B03_FileInput_UI_Support.ts | 36 +- B03_FileInput/B03_FileInput_UI_Upload.ts | 58 +- .../B04_PreProcess_UI_TerrainViewer.ts | 72 +-- .../B07_DesignDetail_Api_Fetch.ts | 41 +- .../B07_DesignDetail_UI_FrameEdit.ts | 23 +- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 121 +--- .../B07_DesignDetail_UI_Style.css | 6 +- .../openwebcad/src/commands/commands.draw.ts | 5 +- .../openwebcad/src/components/Button.tsx | 6 +- .../src/components/DropdownButton.tsx | 10 +- .../openwebcad/src/components/Icon/Icon.tsx | 2 +- .../src/components/InspectorPanel.tsx | 7 +- .../src/components/PropertiesEditor.tsx | 11 +- .../src/drawControllers/svg.drawController.ts | 16 +- .../openwebcad/src/entities/ArcEntity.test.ts | 8 +- .../openwebcad/src/entities/HatchEntity.ts | 8 +- .../src/entities/LineEntity.test.ts | 10 +- .../src/entities/MeasurementEntity.test.ts | 19 +- .../src/entities/MeasurementEntity.ts | 8 +- .../openwebcad/src/helpers/box-to-polygon.ts | 24 +- .../openwebcad/src/helpers/cad-clipboard.ts | 5 +- .../calculate-angle-guides-and-snap-points.ts | 89 ++- .../src/helpers/contain-rect.test.ts | 310 +++++----- .../openwebcad/src/helpers/contain-rect.ts | 83 ++- .../convert-svg-path-to-line-segments.test.ts | 8 +- .../convert-svg-path-to-line-segments.ts | 2 +- .../src/helpers/find-closest-entity.mocks.ts | 4 +- .../src/helpers/find-closest-entity.test.ts | 10 +- .../helpers/find-neighboring-points-on-arc.ts | 48 +- .../find-neighboring-points-on-circle.ts | 46 +- .../find-neighboring-points-on-line.ts | 43 +- .../src/helpers/geometry/entity-loop.ts | 3 +- .../src/helpers/geometry/sample-entity.ts | 9 +- .../src/helpers/geometry/shape-points.ts | 14 +- .../src/helpers/get-angle-guide-lines.ts | 58 +- .../src/helpers/get-angle-with-x-axis.test.ts | 8 +- .../src/helpers/get-angle-with-x-axis.ts | 2 +- .../get-bounding-box-of-multiple-entities.ts | 2 +- .../src/helpers/get-closest-snap-point.ts | 6 +- .../openwebcad/src/helpers/get-draw-guides.ts | 20 +- .../src/helpers/get-intersection-points.ts | 24 +- .../src/helpers/get-point-from-event.ts | 2 +- .../openwebcad/src/helpers/helpers.types.ts | 4 +- .../export-entities-to-json.ts | 10 +- .../export-entities-to-local-storage.ts | 4 +- .../export-entities-to-png.ts | 73 ++- .../export-entities-to-svg.ts | 12 +- .../import-entities-from-local-storage.ts | 10 +- .../import-entities-from-svg.ts | 22 +- .../import-entities-from-svg.types.ts | 14 +- .../import-image-from-file.ts | 20 +- .../src/helpers/is-closed-polygon.test.ts | 8 +- .../src/helpers/is-closed-polygon.ts | 6 +- .../openwebcad/src/helpers/is-length-equal.ts | 2 +- .../openwebcad/src/helpers/is-point-equal.ts | 5 +- .../src/helpers/keyboard-handler.ts | 2 +- .../src/helpers/map-number-range.test.ts | 92 +-- .../src/helpers/map-number-range.ts | 28 +- .../src/helpers/mirror-angle-over-axis.ts | 2 +- .../helpers/mirror-point-over-axis.test.ts | 20 +- .../src/helpers/mirror-point-over-axis.ts | 2 +- .../src/helpers/polygon-to-segments.ts | 2 +- .../openwebcad/src/helpers/rotate-point.ts | 12 +- .../openwebcad/src/helpers/scale-point.ts | 12 +- .../openwebcad/src/helpers/scene-cache.ts | 12 +- .../src/helpers/sort-points-on-arc.ts | 34 +- .../src/helpers/sort-points-on-circle.ts | 23 +- .../openwebcad/src/helpers/times.ts | 19 +- .../src/helpers/track-hovered-snap-points.ts | 119 ++-- .../openwebcad/src/helpers/wrap-module.ts | 2 +- .../openwebcad/src/tools/align-bottom-tool.ts | 18 +- .../src/tools/align-center-horizontal-tool.ts | 26 +- .../openwebcad/src/tools/align-left-tool.ts | 18 +- .../src/tools/align-middle-vertical-tool.ts | 26 +- .../openwebcad/src/tools/align-right-tool.ts | 18 +- .../src/tools/align-tool.helpers.ts | 21 +- .../openwebcad/src/tools/align-top-tool.ts | 18 +- .../tools/annotate/dimension-radial-tools.ts | 2 +- .../openwebcad/src/tools/array-tool.ts | 18 +- .../openwebcad/src/tools/copy-tool.ts | 515 ++++++++-------- .../src/tools/draw/basic-draw-tools.ts | 14 +- .../openwebcad/src/tools/draw/divide-tools.ts | 6 +- .../openwebcad/src/tools/draw/fill-tools.ts | 8 +- .../src/tools/eraser-tool.helpers.ts | 24 +- .../openwebcad/src/tools/eraser-tool.test.ts | 20 +- .../src/tools/image-import-tool.helpers.ts | 2 +- .../openwebcad/src/tools/image-import-tool.ts | 461 +++++++------- .../src/tools/modify/corner-tools.ts | 9 +- .../src/tools/modify/corner.helpers.ts | 10 +- .../src/tools/modify/transform-tools.ts | 6 +- .../openwebcad/src/tools/move-tool.helpers.ts | 2 +- .../openwebcad/src/tools/move-tool.ts | 521 ++++++++-------- .../openwebcad/src/tools/pedit-tool.ts | 12 +- .../src/tools/rotate-tool.helpers.ts | 4 +- .../openwebcad/src/tools/rotate-tool.ts | 566 +++++++++-------- .../src/tools/scale-tool.helpers.ts | 6 +- .../openwebcad/src/tools/scale-tool.ts | 569 +++++++++--------- .../openwebcad/src/tools/select-tool.ts | 337 +++++------ .../openwebcad/src/tools/tool.types.ts | 118 ++-- .../src/tools/utility/clipboard-tools.ts | 6 +- .../src/tools/utility/property-tools.ts | 12 +- .../entities/circle/circle.recording.json | 112 ++-- .../test/entities/circle/circle.test.ts | 47 +- .../test/entities/line/line.test.ts | 48 +- .../test/entities/rectangle/rectangle.test.ts | 18 +- .../openwebcad/test/helpers/click.ts | 6 +- .../test/helpers/init-application.ts | 23 +- .../test/helpers/replay-recording.ts | 10 +- .../test/helpers/replay-recording.types.ts | 40 +- .../test/helpers/set-active-tool.ts | 6 +- .../openwebcad/test/helpers/tests.consts.ts | 2 +- .../screenCanvas.drawController.ts | 500 ++++++++------- .../test/tools/eraser/eraser.recording.json | 318 +++++----- .../test/tools/eraser/eraser.test.ts | 20 +- .../data_global_contours/convert_to_gpkg.py | 51 +- .../original/_pipeline/build_srcmap.py | 358 ++++++++--- .../knowledge/original/_pipeline/check_law.py | 150 +++-- .../_pipeline/collect_cost_sources.py | 82 ++- .../original/_pipeline/extract_zip.py | 10 +- .../original/_pipeline/fix_box_tables.py | 30 +- .../original/_pipeline/fix_law_images.py | 27 +- .../original/_pipeline/fix_spacing.py | 23 +- .../original/_pipeline/get_attach.py | 28 +- .../knowledge/original/_pipeline/get_kcsc.py | 60 +- .../knowledge/original/_pipeline/get_ks.py | 48 +- .../knowledge/original/_pipeline/hwp5_text.py | 40 +- .../knowledge/original/_pipeline/hwpx_text.py | 42 +- .../original/_pipeline/index_entry.py | 26 +- .../original/_pipeline/index_images.py | 58 +- .../knowledge/original/_pipeline/index_zip.py | 11 +- .../knowledge/original/_pipeline/pdf2md.py | 100 +-- .../knowledge/original/_pipeline/qc_lint.py | 40 +- .../original/_pipeline/split_cost_docs.py | 74 ++- .../original/_pipeline/verify_pdf2md.py | 21 +- .../STmate/_scripts/extract_rounding.py | 9 +- .../STmate/_scripts/stc_cross_compare.py | 4 +- .../원가계산/STmate/_scripts/xor_probe.py | 8 +- scratch/test_vworld_download.py | 28 +- ui_template/ui_template_locale_b1.ts | 209 ++----- 143 files changed, 4133 insertions(+), 4103 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2e94d179 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면 +# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫. +B07_DesignDetail/openwebcad/ + +# 빌드·산출물·가상환경 — 포맷 대상이 아니다. +dist/ +venv/ +storage/ +tmp/ +graphify-out/ diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 27265301..e07dbf98 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -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(response); } finally { window.clearTimeout(timeoutId); @@ -92,22 +89,19 @@ export async function createUploadSession( completeUpload = false, lasFree = false, ): Promise { - 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(response); } @@ -138,21 +132,18 @@ export async function finalizeUploadSession( fingerprint?: string | null, lasFree = false, ): Promise { - 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(response); } @@ -160,13 +151,10 @@ export async function fetchUploadStatus( projectId: string, sessionId: string, ): Promise { - 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(response); } @@ -200,16 +188,11 @@ export interface UploadOverviewResponse { } /** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ -export async function fetchUploadOverview( - projectId: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${projectId}/upload-overview`, - { - method: "GET", - credentials: "include", - }, - ); +export async function fetchUploadOverview(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, { + method: "GET", + credentials: "include", + }); return await readJsonOrThrow(response); } @@ -223,15 +206,10 @@ export interface WF1AnalysisStatus { error?: string; } -export async function checkWF1AnalysisStatus( - projectId: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${projectId}/surface/status`, - { - method: "GET", - credentials: "include", - }, - ); +export async function checkWF1AnalysisStatus(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, { + method: "GET", + credentials: "include", + }); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 463629f7..9ed8384c 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -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 { 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 { const cssState = stateName === "failed" ? "error" : stateName; card.classList.add(`b03-file__card--${cssState}`); - const badgeContainer = card.querySelector( - ".b03-file__card-badge-container", - ); + const badgeContainer = card.querySelector(".b03-file__card-badge-container"); if (badgeContainer) { badgeContainer.replaceChildren(); if (stateName === "empty") { @@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (!state || !card) return; renderExtensionLabel(card, state); - const fileName = card.querySelector( - ".b03-file__file-name", - ); - const fileSize = card.querySelector( - ".b03-file__file-size", - ); - const progress = card.querySelector( - ".b03-file__progress-bar", - ); - const progressBytes = card.querySelector( - ".b03-file__progress-bytes", - ); - const progressSpeed = card.querySelector( - ".b03-file__progress-speed", - ); - const progressEta = card.querySelector( - ".b03-file__progress-eta", - ); - const error = card.querySelector( - ".b03-file__error-message", - ); - const remove = card.querySelector( - ".b03-file__card-remove", - ); + const fileName = card.querySelector(".b03-file__file-name"); + const fileSize = card.querySelector(".b03-file__file-size"); + const progress = card.querySelector(".b03-file__progress-bar"); + const progressBytes = card.querySelector(".b03-file__progress-bytes"); + const progressSpeed = card.querySelector(".b03-file__progress-speed"); + const progressEta = card.querySelector(".b03-file__progress-eta"); + const error = card.querySelector(".b03-file__error-message"); + const remove = card.querySelector(".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 { 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 { 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 { + async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { 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 { 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 { 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 { * 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 { // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(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 { : "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 { 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( - ".b03-file__slot-input", - )!; + const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); - const select = card.querySelector( - ".b03-file__card-select", - )!; + const select = card.querySelector(".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( - ".b03-file__card-remove", - )!; + const remove = card.querySelector(".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 { 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 { 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 { } } - async function startChunkedUpload( - targetStates = selectedStates(), - ): Promise { + async function startChunkedUpload(targetStates = selectedStates()): Promise { if (isUploading) return; // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. if (tempPicker.selected()) { @@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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 { 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 { 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 { 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 { 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"); diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 550b1ed7..c0f8768f 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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); } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index ea3502f9..4ee67cc8 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -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 }; }); diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 7f018a6c..dafe030b 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -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 { +export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { 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); diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 215afe8b..75b7c565 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -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; + showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise; 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); diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index defbab99..accd314c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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( - path: string, - init: RequestInit = {}, -): Promise { +async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -116,17 +108,14 @@ async function requestJson( 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 { +export function fetchDesignDrawingList(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -134,9 +123,7 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - 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 { +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { 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 { +export function fetchFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate( - projectId: string, - drawing: CadDrawing, -): Promise { +export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 7e1bdd3d..f7a1fafc 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 32fd5f38..807467e4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 = { 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 { 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 { // 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관). 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 { ) ?? undefined; const highlightActive = (drawingId: string) => { - drawingListEl - ?.querySelectorAll(".b07-drawing-button") - .forEach((item) => { - item.dataset.active = String(item.dataset.drawingId === drawingId); - }); + drawingListEl?.querySelectorAll(".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 { const drawingCache = new Map>(); /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ - const requestDrawing = ( - drawing: DesignDrawingItem, - ): Promise => { + const requestDrawing = (drawing: DesignDrawingItem): Promise => { 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 { } 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 { const requestCadDrawing = (): Promise => 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 { } } 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 { 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 { cadHost.prepend(frameEditor.banner); window.addEventListener("message", (event: MessageEvent) => { - 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 { (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 { } 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 { 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 { } }); - 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 { onStepClick: (stepIndex) => { if (!projectId) return; if (stepIndex > 5 && !allDrawingsConfirmed) { - showToast( - "모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", - "warning", - ); + showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); return; } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 8ba7b7f2..f2ca6ee6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts index 2bd50c85..e5adc8ea 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts @@ -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, diff --git a/B07_DesignDetail/openwebcad/src/components/Button.tsx b/B07_DesignDetail/openwebcad/src/components/Button.tsx index b1f3f087..f95ddc2c 100644 --- a/B07_DesignDetail/openwebcad/src/components/Button.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Button.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx b/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx index 94b2883d..995abf0c 100644 --- a/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx +++ b/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx index 0de5382a..2ac2af46 100644 --- a/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx index 0eb76091..b640e300 100644 --- a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx index 3a2e5e8b..a4410ef4 100644 --- a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx +++ b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx @@ -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 = ({ compact = false })
시작점
-
- {points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'} -
+
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
그룹
diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts index 3103a7d6..61f38903 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts index 33e6b5df..c094d88b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts @@ -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', () => { /** diff --git a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts index d15fcc23..a9b58a27 100644 --- a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts @@ -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]); } diff --git a/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts index 2cdf617a..8b31c097 100644 --- a/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts index 037ea0ce..29077ac3 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts @@ -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', () => ({ diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts index ea3a408e..08504b3b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts index 5e6a74d5..b1406801 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts @@ -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)), + ]); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts index 3667eec5..8dc9d7c9 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts @@ -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; }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts index 4eca92c4..2f2c405f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -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); + } } diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts index e5188651..fa1cc208 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts @@ -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 }); + }); }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts index 3e8d8f56..561f7692 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts @@ -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, + }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts index 846d08b5..24f02b08 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts index cd8b83cc..2a6ec29f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts @@ -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 }; diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts index efd39457..13604e27 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts @@ -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: [ diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts index 6df72399..da946ca4 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts @@ -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 () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts index 9b13a698..888a948d 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts @@ -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], + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts index b8dda812..faa847ae 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts @@ -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], + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts index c6b38a91..92f301ed 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts @@ -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, + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts index 6cd36aa1..625155be 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts index 788d4891..2f3ee170 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts @@ -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(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts index 94bc2ac9..1eec3633 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts index 0ab99254..e9532b43 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts @@ -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; + }); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts index 5df7cd6e..2c311ef3 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts index dd1ed291..cf615d27 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts index b5731a1f..d6b10479 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts @@ -1,4 +1,4 @@ -import type {Entity} from "../entities/Entity.ts"; +import type { Entity } from '../entities/Entity.ts'; export interface BoundingBox { minX: number; diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts index 826442a3..5f77e585 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts index e6ad8a5a..d67e7390 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts index 22eb2703..9e9c13ac 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index 3856a546..76565a4e 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -1,4 +1,4 @@ -import {type Point, Vector} from '@flatten-js/core'; +import { type Point, Vector } from '@flatten-js/core'; import { type AbsolutePointInputEvent, ActorEvent, diff --git a/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts index fc56a4da..3bd2d602 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts @@ -1,6 +1,6 @@ import type { Point } from '@flatten-js/core'; export interface PointWithAngle { - point: Point; - angle: number; + point: Point; + angle: number; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts index b0d59331..1cc97229 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts @@ -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(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts index 73243c8c..1b2ce9e1 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts @@ -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(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts index aa69bdb9..d54b3502 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts @@ -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 { - return new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); + return new Promise((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'); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts index 96898837..2beeef0d 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts @@ -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[]; diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts index 91184e79..fc588a56 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts @@ -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 { const file = await getEntitiesAndLayersFromLocalStorage(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts index 1e0ee91b..76c51e4f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts @@ -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]) { diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts index 6769ce86..3d92c50c 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts @@ -1,12 +1,12 @@ export interface SvgParseResult { - type: string - children: Children[] + type: string; + children: Children[]; } export interface Children { - type: string - tagName: string - properties: Record - children: Children[] - metadata?: string + type: string; + tagName: string; + properties: Record; + children: Children[]; + metadata?: string; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts index bbc74e7c..a1e71e11 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts @@ -3,16 +3,14 @@ * Load the image data * Convert it to a base64 string */ -export function importImageFromFile( - file: File | null | undefined, -): Promise { - return new Promise(resolve => { - if (!file) return; +export function importImageFromFile(file: File | null | undefined): Promise { + return new Promise((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); + }); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts index 9d46988d..83f29ab3 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts @@ -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 { diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts index 37d9aed4..93a58b19 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts index 93e6675d..6ed3ce24 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts index 43eb67d4..644ef38f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts index 9d25cd4d..1f69e423 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts @@ -1,4 +1,4 @@ -import type {KeyboardEvent} from "react"; +import type { KeyboardEvent } from 'react'; export function keyboardHandler(clickHandler: () => void) { return (evt: KeyboardEvent) => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts index 83dddaec..22d4bdc7 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts @@ -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); + }); }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts index 47c1d601..b4df9aec 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts @@ -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) + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts index 5cf8e11e..75e88f55 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts @@ -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(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts index 8bc9c478..8842ef35 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts index b7ea62db..f9e729fc 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts index 5b042adf..c5bae1e7 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts @@ -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[] = []; diff --git a/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts index f4597109..10f7dc21 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts index 57a8eedf..2f50410f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts index b2eb832b..e985ea34 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts index 39a2941a..7854266a 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts @@ -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 + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts index d3c71b23..0cc9dde9 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts @@ -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 + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/times.ts b/B07_DesignDetail/openwebcad/src/helpers/times.ts index aa57cb19..ec02784f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/times.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/times.ts @@ -1,12 +1,9 @@ -export function times( - 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(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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts index e7d96204..c3782990 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts index f3319ac2..9297f4cf 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts @@ -7,5 +7,5 @@ // 3 => 0 // 4 => 1 export function wrapModule(index: number, length: number) { - return (index + length) % length; + return (index + length) % length; } diff --git a/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts index ff4c7fd0..1a38834a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts @@ -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); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts index 9a7c7979..4d5b6855 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts @@ -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); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts index f228e4b6..2a74aa0e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts @@ -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); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts index 6e58ee36..1f835d32 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts @@ -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); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts index 7e1bca6a..b6496f07 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts @@ -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); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts index 53e03856..0f1c2c49 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts @@ -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 {} diff --git a/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts index 844366e2..7213711f 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts @@ -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)); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts index 8e094c2e..174c2603 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts index 752e7895..829a3454 100644 --- a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -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, diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 0b41da5e..7ef246f2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -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, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts index 69be6e07..5e052f81 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts @@ -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[0]): Entity[] { +function donutEntities( + innerDiameter: number, + outerDiameter: number, + center: Parameters[0] +): Entity[] { const circles: Entity[] = []; for (const diameter of [innerDiameter, outerDiameter]) { if (diameter > 0) { diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts index 8199865f..19ddd7c3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts index c0de937a..a0db5784 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts index 22943e86..9238c284 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts index 8aa8d85c..3d21b7f5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts @@ -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', () => { /** diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts index ee7ce6d1..a376eb7c 100644 --- a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts @@ -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, diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts index 44350c7a..81f9b313 100644 --- a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts @@ -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)); + }, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts index 892de32a..425b352a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts @@ -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)) ); }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts index 4aec33db..ff95bf3e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts index be4cc0a1..01475b9d 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts index 8b39afb5..97c15c50 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index 39a8de0c..346be909 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -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, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts index 2e7447d3..d00c3874 100644 --- a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts @@ -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 {} diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts index 8687f39b..d29b34ba 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index b89db405..0d792a54 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -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, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts index fda881b3..fb8284d7 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 3d81e395..99b5ed11 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -1,48 +1,48 @@ -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 {scaleEntities} from './scale-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 { scaleEntities } from './scale-tool.helpers'; export interface ScaleContext extends ToolContext { - baseVectorStartPoint: Point | null; - baseVectorEndPoint: Point | null; - scaleVectorEndPoint: Point | null; - originalSelectedEntities: Entity[]; + baseVectorStartPoint: Point | null; + baseVectorEndPoint: Point | null; + scaleVectorEndPoint: Point | null; + originalSelectedEntities: Entity[]; } export enum ScaleState { - INIT = 'INIT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', - WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT', - WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT', - WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT', + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT', + WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT', + WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT', } export enum ScaleAction { - INIT_SCALE_TOOL = 'INIT_SCALE_TOOL', - ENABLE_HELPERS = 'ENABLE_HELPERS', - RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT', - RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT', - COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', - DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', - SCALE_SELECTION = 'SCALE_SELECTION', - DESELECT_ENTITIES = 'DESELECT_ENTITIES', - RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', + INIT_SCALE_TOOL = 'INIT_SCALE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT', + RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT', + COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', + DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', + SCALE_SELECTION = 'SCALE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } /** @@ -56,271 +56,252 @@ export enum ScaleAction { * When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector */ export const scaleToolStateMachine = createMachine( - { - types: {} as { - context: ScaleContext; - events: StateEvent; - }, - context: { - baseVectorStartPoint: null, - baseVectorEndPoint: null, - scaleVectorEndPoint: null, - originalSelectedEntities: [], - type: Tool.SCALE, - }, - initial: ScaleState.INIT, - states: { - [ScaleState.INIT]: { - description: 'Initializing the scale tool', - always: { - actions: ScaleAction.INIT_SCALE_TOOL, - target: ScaleState.CHECK_SELECTION, - }, - }, - [ScaleState.CHECK_SELECTION]: { - description: 'Check if there is something selected', - always: [ - { - guard: () => { - return getSelectedEntityIds().length > 0; - }, - target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT, - }, - { - guard: () => { - return getSelectedEntityIds().length === 0; - }, - target: ScaleState.WAITING_FOR_SELECTION, - }, - ], - }, - [ScaleState.WAITING_FOR_SELECTION]: { - description: 'Select what you want to scale', - meta: { - instructions: 'Select what you want to scale, then ENTER', - }, - invoke: { - id: 'selectToolInsideTheScaleTool', - src: selectToolStateMachine, - onDone: { - actions: assign(() => { - return { - baseVectorStartPoint: null, - baseVectorEndPoint: null, - scaleVectorEndPoint: null, - originalSelectedEntities: [], - }; - }), - target: ScaleState.CHECK_SELECTION, - }, - }, - on: { - MOUSE_CLICK: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - ESC: { - actions: [ - ScaleAction.DESELECT_ENTITIES, - ScaleAction.INIT_SCALE_TOOL, - ], - }, - ENTER: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - DRAW: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - }, - }, - [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { - description: 'Select the origin of the scale operation', - meta: { - instructions: 'Select the origin of the scale operation', - }, - always: { - actions: ScaleAction.ENABLE_HELPERS, - }, - on: { - MOUSE_CLICK: { - actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT], - target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT, - }, - ESC: { - actions: ScaleAction.DESELECT_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { - description: 'Select the end of the base scale line', - meta: { - instructions: 'Select the end of the base scale line', - }, - on: { - MOUSE_CLICK: { - actions: [ - ScaleAction.RECORD_BASE_VECTOR_END_POINT, - ScaleAction.COPY_SELECTION_BEFORE_SCALE, - ], - target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT, - }, - ESC: { - actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { - description: 'Select the end of the scale line', - meta: { - instructions: 'Select the end of the scale line', - }, - on: { - DRAW: { - actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES], - }, - MOUSE_CLICK: { - actions: [ - ScaleAction.SCALE_SELECTION, - ScaleAction.DESELECT_ENTITIES, - ], - target: ScaleState.WAITING_FOR_SELECTION, - }, - ESC: { - actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - }, - }, - { - actions: { - [ScaleAction.INIT_SCALE_TOOL]: () => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - setAngleGuideOriginPoint(null); - }, - [ScaleAction.ENABLE_HELPERS]: () => { - setShouldDrawHelpers(true); - }, - [ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign( - ({ context, event }) => { - setAngleGuideOriginPoint( - (event as MouseClickEvent).worldMouseLocation, - ); - return { - ...context, - baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign( - ({ context, event }) => { - return { - ...context, - baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => { - const selectedEntities = getSelectedEntities(); + { + types: {} as { + context: ScaleContext; + events: StateEvent; + }, + context: { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + type: Tool.SCALE, + }, + initial: ScaleState.INIT, + states: { + [ScaleState.INIT]: { + description: 'Initializing the scale tool', + always: { + actions: ScaleAction.INIT_SCALE_TOOL, + target: ScaleState.CHECK_SELECTION, + }, + }, + [ScaleState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: ScaleState.WAITING_FOR_SELECTION, + }, + ], + }, + [ScaleState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to scale', + meta: { + instructions: 'Select what you want to scale, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheScaleTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + }; + }), + target: ScaleState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [ScaleAction.DESELECT_ENTITIES, ScaleAction.INIT_SCALE_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { + description: 'Select the origin of the scale operation', + meta: { + instructions: 'Select the origin of the scale operation', + }, + always: { + actions: ScaleAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT], + target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.DESELECT_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { + description: 'Select the end of the base scale line', + meta: { + instructions: 'Select the end of the base scale line', + }, + on: { + MOUSE_CLICK: { + actions: [ + ScaleAction.RECORD_BASE_VECTOR_END_POINT, + ScaleAction.COPY_SELECTION_BEFORE_SCALE, + ], + target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { + description: 'Select the end of the scale line', + meta: { + instructions: 'Select the end of the scale line', + }, + on: { + DRAW: { + actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [ScaleAction.SCALE_SELECTION, ScaleAction.DESELECT_ENTITIES], + target: ScaleState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [ScaleAction.INIT_SCALE_TOOL]: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + [ScaleAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(({ context, event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + ...context, + baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(({ context, event }) => { + return { + ...context, + baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); - // Scale 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); - // Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides - deleteEntities(selectedEntities, false); + // Scale 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); + // Rescale 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 scaled 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 scaled and the original entities also are used for snap points / angle guides - setSelectedEntityIds([]); - return { - ...context, - // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact( - selectedEntities.map(entity => entity.clone()), - ), - }; - }), - [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { - if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { - throw new Error( - '[SCALE] Calling draw temp scale entities without a base start point or base end point', - ); - } + setSelectedEntityIds([]); + return { + ...context, + // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action + originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + }; + }), + [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error( + '[SCALE] Calling draw temp scale entities without a base start point or base end point' + ); + } - const scaleVectorEndPointTemp = ( - event as DrawEvent - ).drawController.getWorldMouseLocation(); + const scaleVectorEndPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); - // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling - const scaledEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - scaleEntities( - scaledEntities, - context.baseVectorStartPoint, - context.baseVectorEndPoint, - scaleVectorEndPointTemp, - ); + // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling + const scaledEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPointTemp + ); - setGhostHelperEntities(scaledEntities); - }, - [ScaleAction.SCALE_SELECTION]: ({ context, event }) => { - if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { - throw new Error( - '[SCALE] Calling scale selection without some scale vector endpoints', - ); - } - const scaleVectorEndPoint = (event as MouseClickEvent) - .worldMouseLocation; + setGhostHelperEntities(scaledEntities); + }, + [ScaleAction.SCALE_SELECTION]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error('[SCALE] Calling scale selection without some scale vector endpoints'); + } + const scaleVectorEndPoint = (event as MouseClickEvent).worldMouseLocation; - // Scale the entities one final time - const scaledEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - scaleEntities( - scaledEntities, - context.baseVectorStartPoint, - context.baseVectorEndPoint, - scaleVectorEndPoint, - ); + // Scale the entities one final time + const scaledEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPoint + ); - // Switch the scaled entities back from the ghost helper entities to the real entities - addEntities(scaledEntities, true); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - }, - [ScaleAction.DESELECT_ENTITIES]: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - [ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { - addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack - setGhostHelperEntities([]); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - ...selectToolStateMachine.implementations.actions, - }, - }, + // Switch the scaled entities back from the ghost helper entities to the real entities + addEntities(scaledEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [ScaleAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack + setGhostHelperEntities([]); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts index 5b99270f..55a33926 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -1,180 +1,181 @@ -import type {Point} from '@flatten-js/core'; -import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {Tool} from '../tools'; -import {assign, createMachine} from 'xstate'; -import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers'; +import type { Point } from '@flatten-js/core'; +import { + getNotSelectedEntities, + setEntities, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { Tool } from '../tools'; +import { assign, createMachine } from 'xstate'; +import { + drawTempSelectionRectangle, + handleFirstSelectionPoint, + selectEntitiesInsideRectangle, +} from './select-tool.helpers'; export interface SelectContext extends ToolContext { - startPoint: Point | null; + startPoint: Point | null; } export enum SelectState { - INIT = 'INIT', - WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT', - SELECTION_COMPLETED = 'SELECTION_COMPLETED', + INIT = 'INIT', + WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT', + SELECTION_COMPLETED = 'SELECTION_COMPLETED', } export enum SelectAction { - INIT_SELECT_TOOL = 'INIT_SELECT_TOOL', - HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT', - SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE', - DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE', - DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES', + INIT_SELECT_TOOL = 'INIT_SELECT_TOOL', + HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT', + SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE', + DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE', + DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES', } export const selectToolStateMachine = createMachine( - { - types: {} as { - context: SelectContext; - events: StateEvent; - }, - context: { - startPoint: null, - type: Tool.SELECT, - }, - initial: SelectState.INIT, - states: { - [SelectState.INIT]: { - description: 'Initializing the select tool', - always: { - actions: SelectAction.INIT_SELECT_TOOL, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - }, - [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { - description: - 'Select a line or select the first point of a selection rectangle', - meta: { - instructions: 'Select a line or start drawing a selection rectangle', - }, - on: { - MOUSE_CLICK: { - actions: SelectAction.HANDLE_FIRST_SELECT_POINT, - target: SelectState.CHECK_SELECTION, - }, - ESC: { - actions: SelectAction.INIT_SELECT_TOOL, - }, - ENTER: { - target: SelectState.SELECTION_COMPLETED, - }, - DELETE: { - actions: SelectAction.DELETE_SELECTED_ENTITIES, - target: SelectState.INIT, - }, - }, - }, - [SelectState.CHECK_SELECTION]: { - description: - 'Checking to select one line or start drawing a selection rectangle', - meta: { - instructions: - 'Select one line or start drawing a selection rectangle', - }, - always: [ - { - // User started drawing a selection rectangle - guard: ({ context }: { context: SelectContext }) => - !!context.startPoint, - target: SelectState.WAITING_FOR_SECOND_SELECT_POINT, - }, - { - // User clicked on an entity - guard: ({ context }: { context: SelectContext }) => - !context.startPoint, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - ], - }, - [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { - description: 'Select the second point of a selection rectangle', - meta: { - instructions: 'Select the second point of a selection rectangle', - }, - on: { - DRAW: { - actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE, - }, - MOUSE_CLICK: { - actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - ESC: { - target: SelectState.INIT, - }, - }, - }, - [SelectState.SELECTION_COMPLETED]: { - description: 'Selection completed', - type: 'final', - }, - }, - }, - { - actions: { - INIT_SELECT_TOOL: () => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - }, - HANDLE_FIRST_SELECT_POINT: assign( - ({ context, event }: { context: SelectContext; event: StateEvent }) => { - return handleFirstSelectionPoint(context, event as MouseClickEvent); - }, - ), - DRAW_TEMP_SELECTION_RECTANGLE: ({ - context, - event, - }: { - context: SelectContext; - event: StateEvent; - }) => { - if (!context.startPoint) { - // assert - throw new Error( - '[SELECT] Calling drawTempSelectionRectangle without startPoint set', - ); - } - drawTempSelectionRectangle( - context.startPoint as Point, - (event as DrawEvent).drawController.getWorldMouseLocation(), - ); - }, - SELECT_ENTITIES_INSIDE_RECTANGLE: ({ - context, - event, - }: { - context: SelectContext; - event: StateEvent; - }) => { - if (!context.startPoint) { - // - throw new Error( - '[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point', - ); - } - selectEntitiesInsideRectangle( - context.startPoint, - (event as MouseClickEvent).worldMouseLocation, - (event as MouseClickEvent).holdingCtrl, - // (event as MouseClickEvent).holdingShift, - ); - setGhostHelperEntities([]); - }, - DELETE_SELECTED_ENTITIES: () => { - setEntities(getNotSelectedEntities(), true); - setSelectedEntityIds([]); - setGhostHelperEntities([]); - }, - RESET_SELECTION: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - }; - }), - }, - }, + { + types: {} as { + context: SelectContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.SELECT, + }, + initial: SelectState.INIT, + states: { + [SelectState.INIT]: { + description: 'Initializing the select tool', + always: { + actions: SelectAction.INIT_SELECT_TOOL, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + }, + [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { + description: 'Select a line or select the first point of a selection rectangle', + meta: { + instructions: 'Select a line or start drawing a selection rectangle', + }, + on: { + MOUSE_CLICK: { + actions: SelectAction.HANDLE_FIRST_SELECT_POINT, + target: SelectState.CHECK_SELECTION, + }, + ESC: { + actions: SelectAction.INIT_SELECT_TOOL, + }, + ENTER: { + target: SelectState.SELECTION_COMPLETED, + }, + DELETE: { + actions: SelectAction.DELETE_SELECTED_ENTITIES, + target: SelectState.INIT, + }, + }, + }, + [SelectState.CHECK_SELECTION]: { + description: 'Checking to select one line or start drawing a selection rectangle', + meta: { + instructions: 'Select one line or start drawing a selection rectangle', + }, + always: [ + { + // User started drawing a selection rectangle + guard: ({ context }: { context: SelectContext }) => !!context.startPoint, + target: SelectState.WAITING_FOR_SECOND_SELECT_POINT, + }, + { + // User clicked on an entity + guard: ({ context }: { context: SelectContext }) => !context.startPoint, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ], + }, + [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { + description: 'Select the second point of a selection rectangle', + meta: { + instructions: 'Select the second point of a selection rectangle', + }, + on: { + DRAW: { + actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE, + }, + MOUSE_CLICK: { + actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ESC: { + target: SelectState.INIT, + }, + }, + }, + [SelectState.SELECTION_COMPLETED]: { + description: 'Selection completed', + type: 'final', + }, + }, + }, + { + actions: { + INIT_SELECT_TOOL: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + HANDLE_FIRST_SELECT_POINT: assign( + ({ context, event }: { context: SelectContext; event: StateEvent }) => { + return handleFirstSelectionPoint(context, event as MouseClickEvent); + } + ), + DRAW_TEMP_SELECTION_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // assert + throw new Error('[SELECT] Calling drawTempSelectionRectangle without startPoint set'); + } + drawTempSelectionRectangle( + context.startPoint as Point, + (event as DrawEvent).drawController.getWorldMouseLocation() + ); + }, + SELECT_ENTITIES_INSIDE_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // + throw new Error('[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point'); + } + selectEntitiesInsideRectangle( + context.startPoint, + (event as MouseClickEvent).worldMouseLocation, + (event as MouseClickEvent).holdingCtrl + // (event as MouseClickEvent).holdingShift, + ); + setGhostHelperEntities([]); + }, + DELETE_SELECTED_ENTITIES: () => { + setEntities(getNotSelectedEntities(), true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + }, + RESET_SELECTION: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + }; + }), + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts index 182629a8..757e8d39 100644 --- a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts +++ b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts @@ -4,114 +4,110 @@ import type { EventObject } from 'xstate'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; export enum ActionType { - Click = 'Click', - TypedCommand = 'TypedCommand', - ActivateTool = 'ActivateTool', + Click = 'Click', + TypedCommand = 'TypedCommand', + ActivateTool = 'ActivateTool', } export interface ClickEvent { - worldMouseLocation: Point; - holdingCtrl: boolean; - holdingShift: boolean; + worldMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; } export interface TypedCommandEvent { - text: string; + text: string; } export interface ToolHandler { - handleToolActivate(): void; - handleToolClick( - worldMouseLocation: Point, - holdingCtrl: boolean, - holdingShift: boolean, - ): void; - handleToolTypedCommand(command: string): void; + handleToolActivate(): void; + handleToolClick(worldMouseLocation: Point, holdingCtrl: boolean, holdingShift: boolean): void; + handleToolTypedCommand(command: string): void; } export enum ActorEvent { - MOUSE_CLICK = 'MOUSE_CLICK', - ESC = 'ESC', - ENTER = 'ENTER', - DELETE = 'DELETE', - DRAW = 'DRAW', - FILE_SELECTED = 'FILE_SELECTED', - NUMBER_INPUT = 'NUMBER_INPUT', - TEXT_INPUT = 'TEXT_INPUT', - ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT', - RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT', + MOUSE_CLICK = 'MOUSE_CLICK', + ESC = 'ESC', + ENTER = 'ENTER', + DELETE = 'DELETE', + DRAW = 'DRAW', + FILE_SELECTED = 'FILE_SELECTED', + NUMBER_INPUT = 'NUMBER_INPUT', + TEXT_INPUT = 'TEXT_INPUT', + ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT', + RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT', } export interface MouseClickEvent extends EventObject { - type: ActorEvent.MOUSE_CLICK; - worldMouseLocation: Point; - screenMouseLocation: Point; - holdingCtrl: boolean; - holdingShift: boolean; + type: ActorEvent.MOUSE_CLICK; + worldMouseLocation: Point; + screenMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; } export interface KeyboardEscEvent extends EventObject { - type: ActorEvent.ESC; + type: ActorEvent.ESC; } export interface KeyboardEnterEvent extends EventObject { - type: ActorEvent.ENTER; + type: ActorEvent.ENTER; } export interface KeyboardDeleteEvent extends EventObject { - type: ActorEvent.DELETE; + type: ActorEvent.DELETE; } export interface NumberInputEvent extends EventObject { - type: ActorEvent.NUMBER_INPUT; - value: number; - worldMouseLocation: Point; + type: ActorEvent.NUMBER_INPUT; + value: number; + worldMouseLocation: Point; } export interface TextInputEvent extends EventObject { - type: ActorEvent.TEXT_INPUT; - value: string; + type: ActorEvent.TEXT_INPUT; + value: string; } export interface AbsolutePointInputEvent extends EventObject { - type: ActorEvent.ABSOLUTE_POINT_INPUT; - value: Point; + type: ActorEvent.ABSOLUTE_POINT_INPUT; + value: Point; } export interface RelativePointInputEvent extends EventObject { - type: ActorEvent.RELATIVE_POINT_INPUT; - value: Point; + type: ActorEvent.RELATIVE_POINT_INPUT; + value: Point; } export interface FileSelectedEvent extends EventObject { - type: ActorEvent.FILE_SELECTED; - image: HTMLImageElement; + type: ActorEvent.FILE_SELECTED; + image: HTMLImageElement; } export interface DrawEvent extends EventObject { - type: ActorEvent.DRAW; - drawController: ScreenCanvasDrawController; + type: ActorEvent.DRAW; + drawController: ScreenCanvasDrawController; } export type PointInputEvent = - | DrawEvent - | MouseClickEvent - | NumberInputEvent - | AbsolutePointInputEvent - | RelativePointInputEvent; + | DrawEvent + | MouseClickEvent + | NumberInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent; export type StateEvent = - | MouseClickEvent - | KeyboardEscEvent - | KeyboardEnterEvent - | KeyboardDeleteEvent - | NumberInputEvent - | TextInputEvent - | AbsolutePointInputEvent - | RelativePointInputEvent - | FileSelectedEvent - | DrawEvent; + | MouseClickEvent + | KeyboardEscEvent + | KeyboardEnterEvent + | KeyboardDeleteEvent + | NumberInputEvent + | TextInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent + | FileSelectedEvent + | DrawEvent; export interface ToolContext { - type: Tool; + type: Tool; } diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts index b20d8ecd..c5017685 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts @@ -1,6 +1,10 @@ /** 클립보드 명령 — 잘라내기·복사·붙여넣기 (조사표 3절 클립보드 패널) */ import { toast } from 'react-toastify'; -import { copyToClipboard, hasClipboardContent, pasteFromClipboard } from '../../helpers/cad-clipboard'; +import { + copyToClipboard, + hasClipboardContent, + pasteFromClipboard, +} from '../../helpers/cad-clipboard'; import { addEntities, deleteEntities, diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts index 32b97f24..24f6a55a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts @@ -1,6 +1,10 @@ /** 특성 명령 — 투명도와 특성 팔레트 열기 (조사표 3절 특성 패널) */ import { toast } from 'react-toastify'; -import { openInspector, setQuickPropertiesVisible, isQuickPropertiesVisible } from '../../components/ui-state'; +import { + openInspector, + setQuickPropertiesVisible, + isQuickPropertiesVisible, +} from '../../components/ui-state'; import { getEntities, setEntities, setSelectedEntityIds } from '../../state'; import { Tool } from '../../tools'; import { createSequenceTool } from '../factories/sequence-tool'; @@ -70,7 +74,11 @@ export const lineTypeToolStateMachine = createSequenceTool({ helpers: false, steps: [ { kind: 'selection', instructions: '선종류를 바꿀 객체를 선택한 뒤 ENTER.' }, - { kind: 'text', instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', defaultValue: '실선' }, + { + kind: 'text', + instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', + defaultValue: '실선', + }, ], commit: (input) => { const key = input.text(1).trim(); diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json index 4db26e47..35e8ba75 100644 --- a/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json @@ -1,60 +1,60 @@ { - "title": "circle 5", - "selectorAttribute": "data-id", - "steps": [ + "title": "circle 5", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ { - "type": "setViewport", - "width": 1278, - "height": 1430, - "deviceScaleFactor": 1, - "isMobile": false, - "hasTouch": false, - "isLandscape": false - }, - { - "type": "navigate", - "url": "http://localhost:5173/", - "assertedEvents": [ - { - "type": "navigation", - "url": "http://localhost:5173/", - "title": "Open WebCAD" - } - ] - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='circle-button'] svg"], - ["xpath///*[@data-id=\"circle-button\"]/div/svg"], - ["pierce/[data-id='circle-button'] svg"], - ["aria/Circle (c)", "aria/[role=\"image\"]"] - ], - "offsetY": 14, - "offsetX": 7 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 257, - "offsetX": 325 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 424, - "offsetX": 366 + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" } - ] + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='circle-button'] svg"], + ["xpath///*[@data-id=\"circle-button\"]/div/svg"], + ["pierce/[data-id='circle-button'] svg"], + ["aria/Circle (c)", "aria/[role=\"image\"]"] + ], + "offsetY": 14, + "offsetX": 7 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 257, + "offsetX": 325 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 424, + "offsetX": 366 + } + ] } diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts index f8724213..f911f27e 100644 --- a/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts @@ -2,38 +2,35 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {getEntities} from '../../../src/state'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import {initApplication} from '../../helpers/init-application'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; -import type {CircleJsonData} from '../../../src/entities/CircleEntity'; +import { expect, test } from 'vitest'; +import { getEntities } from '../../../src/state'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import { initApplication } from '../../helpers/init-application'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; +import type { CircleJsonData } from '../../../src/entities/CircleEntity'; import circleRecording from './circle.recording.json'; -import {replayRecording} from '../../helpers/replay-recording'; +import { replayRecording } from '../../helpers/replay-recording'; test('Draw circle', async () => { - const inputController = initApplication(); + const inputController = initApplication(); - replayRecording(inputController, circleRecording); + replayRecording(inputController, circleRecording); - const entities = getEntities(); + const entities = getEntities(); - expect(entities).toHaveLength(1); + expect(entities).toHaveLength(1); - const circleEntity = entities[0]; - expect(circleEntity.getType()).toBe(EntityName.Circle); - const circleJson = - (await circleEntity.toJson()) as JsonEntity; + const circleEntity = entities[0]; + expect(circleEntity.getType()).toBe(EntityName.Circle); + const circleJson = (await circleEntity.toJson()) as JsonEntity; - expect(circleJson.lineColor).toBe('#fff'); - expect(circleJson.lineWidth).toBe(1); - expect(circleJson.type).toBe('Circle'); - expect(circleJson.shapeData.center.x).toBe(325); - expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257); + expect(circleJson.lineColor).toBe('#fff'); + expect(circleJson.lineWidth).toBe(1); + expect(circleJson.type).toBe('Circle'); + expect(circleJson.shapeData.center.x).toBe(325); + expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257); - const diffX = 424 - 257; - const diffY = 366 - 325; - expect(circleJson.shapeData.radius).toBe( - Math.sqrt(diffX * diffX + diffY * diffY), - ); + const diffX = 424 - 257; + const diffY = 366 - 325; + expect(circleJson.shapeData.radius).toBe(Math.sqrt(diffX * diffX + diffY * diffY)); }); diff --git a/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts index 800b255e..ccfb7bc0 100644 --- a/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts @@ -2,32 +2,32 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {getEntities} from '../../../src/state'; -import {Tool} from '../../../src/tools'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import {initApplication} from '../../helpers/init-application'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; -import {click} from '../../helpers/click'; -import type {LineJsonData} from '../../../src/entities/LineEntity'; -import {setActiveTool} from '../../helpers/set-active-tool'; +import { expect, test } from 'vitest'; +import { getEntities } from '../../../src/state'; +import { Tool } from '../../../src/tools'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import { initApplication } from '../../helpers/init-application'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; +import { click } from '../../helpers/click'; +import type { LineJsonData } from '../../../src/entities/LineEntity'; +import { setActiveTool } from '../../helpers/set-active-tool'; test('Draw line', async () => { - const inputController = initApplication(); - setActiveTool(Tool.LINE); - click(inputController, 185, 94); - click(inputController, 740, 395); - const entities = getEntities(); + const inputController = initApplication(); + setActiveTool(Tool.LINE); + click(inputController, 185, 94); + click(inputController, 740, 395); + const entities = getEntities(); - const lineEntity = entities[0]; - expect(lineEntity.getType()).toBe(EntityName.Line); - const lineJson = (await lineEntity.toJson()) as JsonEntity; + const lineEntity = entities[0]; + expect(lineEntity.getType()).toBe(EntityName.Line); + const lineJson = (await lineEntity.toJson()) as JsonEntity; - expect(lineJson.lineColor).toBe('#fff'); - expect(lineJson.lineWidth).toBe(1); - expect(lineJson.type).toBe('Line'); - expect(lineJson.shapeData.startPoint.x).toBe(185); - expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94); - expect(lineJson.shapeData.endPoint.x).toBe(740); - expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395); + expect(lineJson.lineColor).toBe('#fff'); + expect(lineJson.lineWidth).toBe(1); + expect(lineJson.type).toBe('Line'); + expect(lineJson.shapeData.startPoint.x).toBe(185); + expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94); + expect(lineJson.shapeData.endPoint.x).toBe(740); + expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395); }); diff --git a/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts index be0e7d17..214e2619 100644 --- a/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts @@ -2,15 +2,15 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import type {RectangleJsonData} from '../../../src/entities/RectangleEntity'; -import {getEntities} from '../../../src/state'; -import {Tool} from '../../../src/tools'; -import {click} from '../../helpers/click'; -import {initApplication} from '../../helpers/init-application'; -import {setActiveTool} from '../../helpers/set-active-tool'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; +import { expect, test } from 'vitest'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import type { RectangleJsonData } from '../../../src/entities/RectangleEntity'; +import { getEntities } from '../../../src/state'; +import { Tool } from '../../../src/tools'; +import { click } from '../../helpers/click'; +import { initApplication } from '../../helpers/init-application'; +import { setActiveTool } from '../../helpers/set-active-tool'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; test('Draw circle', async () => { const inputController = initApplication(); diff --git a/B07_DesignDetail/openwebcad/test/helpers/click.ts b/B07_DesignDetail/openwebcad/test/helpers/click.ts index 38b980c7..88b549b8 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/click.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/click.ts @@ -1,6 +1,6 @@ -import {TOOLBAR_WIDTH} from '../../src/App.consts'; -import {MouseButton} from '../../src/App.types'; -import type {InputController} from '../../src/inputController/input-controller'; +import { TOOLBAR_WIDTH } from '../../src/App.consts'; +import { MouseButton } from '../../src/App.types'; +import type { InputController } from '../../src/inputController/input-controller'; /** * Trigger a click event on the canvas diff --git a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts index 3d99fee4..bda67434 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts @@ -1,12 +1,17 @@ -import {Point} from '@flatten-js/core'; -import {Actor} from 'xstate'; -import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenCanvas.drawController'; -import {InputController} from '../../src/inputController/input-controller'; -import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state'; -import {Tool} from '../../src/tools'; -import {TOOL_STATE_MACHINES} from '../../src/commands/registry'; -import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController'; -import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts'; +import { Point } from '@flatten-js/core'; +import { Actor } from 'xstate'; +import type { ScreenCanvasDrawController } from '../../src/drawControllers/screenCanvas.drawController'; +import { InputController } from '../../src/inputController/input-controller'; +import { + setActiveToolActor, + setEntities, + setInputController, + setScreenCanvasDrawController, +} from '../../src/state'; +import { Tool } from '../../src/tools'; +import { TOOL_STATE_MACHINES } from '../../src/commands/registry'; +import { ScreenCanvasDrawController as ScreenCanvasDrawControllerMock } from '../mocks/drawControllers/screenCanvas.drawController'; +import { CANVAS_HEIGHT, CANVAS_WIDTH } from './tests.consts'; export function initApplication(): InputController { const inputController = new InputController(); diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts index a6711309..749e8560 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts @@ -1,8 +1,8 @@ -import type {InputController} from '../../src/inputController/input-controller'; -import {Tool} from '../../src/tools'; -import {click} from './click'; -import type {Recording, Step} from './replay-recording.types'; -import {setActiveTool} from './set-active-tool'; +import type { InputController } from '../../src/inputController/input-controller'; +import { Tool } from '../../src/tools'; +import { click } from './click'; +import type { Recording, Step } from './replay-recording.types'; +import { setActiveTool } from './set-active-tool'; const DATA_ID_TO_TOOL_NAME: Record = { 'select-button': Tool.SELECT, diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts index d0c90b97..adf621b8 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts @@ -1,28 +1,28 @@ export interface Recording { - title: string; - selectorAttribute: string; - steps: Step[]; + title: string; + selectorAttribute: string; + steps: Step[]; } export interface Step { - type: string; - width?: number; - height?: number; - deviceScaleFactor?: number; - isMobile?: boolean; - hasTouch?: boolean; - isLandscape?: boolean; - url?: string; - assertedEvents?: AssertedEvent[]; - target?: string; - selectors?: string[][]; - offsetY?: number; - offsetX?: number; - key?: string; + type: string; + width?: number; + height?: number; + deviceScaleFactor?: number; + isMobile?: boolean; + hasTouch?: boolean; + isLandscape?: boolean; + url?: string; + assertedEvents?: AssertedEvent[]; + target?: string; + selectors?: string[][]; + offsetY?: number; + offsetX?: number; + key?: string; } export interface AssertedEvent { - type: string; - url: string; - title: string; + type: string; + url: string; + title: string; } diff --git a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts index 83b5cb3f..370e4dbc 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts @@ -4,8 +4,8 @@ import { Actor } from 'xstate'; import { TOOL_STATE_MACHINES } from '../../src/commands/registry'; export function setActiveTool(toolName: Tool) { - getActiveToolActor()?.stop(); + getActiveToolActor()?.stop(); - const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); - setActiveToolActor(newToolActor); + const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); + setActiveToolActor(newToolActor); } diff --git a/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts index 54701e4f..f3f085c0 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts @@ -1,4 +1,4 @@ -import {TOOLBAR_WIDTH} from "../../src/App.consts"; +import { TOOLBAR_WIDTH } from '../../src/App.consts'; export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH; export const CANVAS_HEIGHT = 1080; diff --git a/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts index 6dc45847..f417cc83 100644 --- a/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ // noinspection JSUnusedLocalSymbols -import {Point, type Vector} from '@flatten-js/core'; -import type {DrawController} from '../../../src/drawControllers/DrawController'; -import {triggerReactUpdate} from '../../../src/state'; -import {StateVariable} from '../../../src/helpers/undo-stack'; -import {MOUSE_ZOOM_MULTIPLIER} from '../../../src/App.consts'; -import {mapNumberRange} from '../../../src/helpers/map-number-range'; +import { Point, type Vector } from '@flatten-js/core'; +import type { DrawController } from '../../../src/drawControllers/DrawController'; +import { triggerReactUpdate } from '../../../src/state'; +import { StateVariable } from '../../../src/helpers/undo-stack'; +import { MOUSE_ZOOM_MULTIPLIER } from '../../../src/App.consts'; +import { mapNumberRange } from '../../../src/helpers/map-number-range'; /** * Screen coordinate system: @@ -29,289 +29,267 @@ import {mapNumberRange} from '../../../src/helpers/map-number-range'; * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ export class ScreenCanvasDrawController implements DrawController { - private screenOffset: Point = new Point(0, 0); - private screenScale = 1; - private worldMouseLocation: Point; + private screenOffset: Point = new Point(0, 0); + private screenScale = 1; + private worldMouseLocation: Point; - constructor( - private context: CanvasRenderingContext2D | null, - private canvasSize: Point, - ) { - this.worldMouseLocation = this.targetToWorld( - new Point(canvasSize.x / 2, canvasSize.y / 2), - ); - this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down - } + constructor( + private context: CanvasRenderingContext2D | null, + private canvasSize: Point + ) { + this.worldMouseLocation = this.targetToWorld(new Point(canvasSize.x / 2, canvasSize.y / 2)); + this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down + } - public getCanvasSize() { - return this.canvasSize; - } + public getCanvasSize() { + return this.canvasSize; + } - public getScreenScale() { - return this.screenScale; - } + public getScreenScale() { + return this.screenScale; + } - public setScreenScale(newScreenScale: number) { - this.screenScale = newScreenScale; - triggerReactUpdate(StateVariable.screenZoom); - } + public setScreenScale(newScreenScale: number) { + this.screenScale = newScreenScale; + triggerReactUpdate(StateVariable.screenZoom); + } - public getScreenOffset() { - return this.screenOffset; - } + public getScreenOffset() { + return this.screenOffset; + } - public setScreenOffset(newScreenOffset: Point) { - this.screenOffset = newScreenOffset; - triggerReactUpdate(StateVariable.screenOffset); - } + public setScreenOffset(newScreenOffset: Point) { + this.screenOffset = newScreenOffset; + triggerReactUpdate(StateVariable.screenOffset); + } - public setScreenMouseLocation(newScreenMouseLocation: Point): void { - this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation); - triggerReactUpdate(StateVariable.screenMouseLocation); - } + public setScreenMouseLocation(newScreenMouseLocation: Point): void { + this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation); + triggerReactUpdate(StateVariable.screenMouseLocation); + } - public getWorldMouseLocation(): Point { - return this.worldMouseLocation; - } + public getWorldMouseLocation(): Point { + return this.worldMouseLocation; + } - public getScreenMouseLocation(): Point { - return this.worldToTarget(this.worldMouseLocation); - } + public getScreenMouseLocation(): Point { + return this.worldToTarget(this.worldMouseLocation); + } - public panScreen(screenOffsetX: number, screenOffsetY: number) { - this.screenOffset = new Point( - this.screenOffset.x - screenOffsetX / this.screenScale, - this.screenOffset.y - screenOffsetY / this.screenScale, - ); - } + public panScreen(screenOffsetX: number, screenOffsetY: number) { + this.screenOffset = new Point( + this.screenOffset.x - screenOffsetX / this.screenScale, + this.screenOffset.y - screenOffsetY / this.screenScale + ); + } - /** - * This function takes the deltaY from the mouse wheel event and zooms the screen in or out - * The location of the mouse in world space is preserved - * @param deltaY - */ - public zoomScreen(deltaY: number) { - const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); - const newScreenScale = - this.getScreenScale() * - (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY))); - this.setScreenScale(newScreenScale); + /** + * This function takes the deltaY from the mouse wheel event and zooms the screen in or out + * The location of the mouse in world space is preserved + * @param deltaY + */ + public zoomScreen(deltaY: number) { + const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); + const newScreenScale = + this.getScreenScale() * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY))); + this.setScreenScale(newScreenScale); - // now get the location of the cursor in world space again - // It will have changed because the scale has changed, - // but we can offset our world now to fix the zoom location in screen space, - // because we know how much it changed laterally between the two spatial scales. - const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); + // now get the location of the cursor in world space again + // It will have changed because the scale has changed, + // but we can offset our world now to fix the zoom location in screen space, + // because we know how much it changed laterally between the two spatial scales. + const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); - // Adjust the screen offset to maintain the cursor position - this.screenOffset = new Point( - this.screenOffset.x + - (worldMouseLocationBeforeZoom.x - - worldMouseLocationAfterZoom.x), - this.screenOffset.y + - (worldMouseLocationBeforeZoom.y - - worldMouseLocationAfterZoom.y), - ); - } + // Adjust the screen offset to maintain the cursor position + this.screenOffset = new Point( + this.screenOffset.x + (worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x), + this.screenOffset.y + (worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y) + ); + } - /** - * Convert coordinates from World Space --> Screen Space - */ - public worldToTarget(worldCoordinate: Point): Point { - return new Point( - mapNumberRange( - worldCoordinate.x, - this.screenOffset.x, - this.screenOffset.x + this.canvasSize.x / this.screenScale, - 0, - this.canvasSize.x, - ), - mapNumberRange( - worldCoordinate.y, - this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left - this.screenOffset.y, - 0, - this.canvasSize.y, - ), - ); - } + /** + * Convert coordinates from World Space --> Screen Space + */ + public worldToTarget(worldCoordinate: Point): Point { + return new Point( + mapNumberRange( + worldCoordinate.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale, + 0, + this.canvasSize.x + ), + mapNumberRange( + worldCoordinate.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left + this.screenOffset.y, + 0, + this.canvasSize.y + ) + ); + } - public worldsToTargets(worldCoordinates: Point[]): Point[] { - return worldCoordinates.map(this.worldToTarget.bind(this)); - } + public worldsToTargets(worldCoordinates: Point[]): Point[] { + return worldCoordinates.map(this.worldToTarget.bind(this)); + } - /** - * Convert coordinates from Screen Space --> World Space - * (0, 0) (1920, 0) - * - * (0, 1080) (1920, 1080) - * - * convert to - * - * (0, 1080) (1920, 1080) - * - * (0, 0) (1920, 0) - */ - public targetToWorld(screenCoordinate: Point): Point { - // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() - return new Point( - mapNumberRange( - screenCoordinate.x, - 0, - this.canvasSize.x, - this.screenOffset.x, - this.screenOffset.x + this.canvasSize.x / this.screenScale, - ), - mapNumberRange( - screenCoordinate.y, - 0, - this.canvasSize.y, - this.screenOffset.y, - this.screenOffset.y + this.canvasSize.y / this.screenScale, - ), - ); - } + /** + * Convert coordinates from Screen Space --> World Space + * (0, 0) (1920, 0) + * + * (0, 1080) (1920, 1080) + * + * convert to + * + * (0, 1080) (1920, 1080) + * + * (0, 0) (1920, 0) + */ + public targetToWorld(screenCoordinate: Point): Point { + // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() + return new Point( + mapNumberRange( + screenCoordinate.x, + 0, + this.canvasSize.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale + ), + mapNumberRange( + screenCoordinate.y, + 0, + this.canvasSize.y, + this.screenOffset.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale + ) + ); + } - public targetsToWorlds(screenCoordinates: Point[]): Point[] { - return screenCoordinates.map(this.targetToWorld.bind(this)); - } + public targetsToWorlds(screenCoordinates: Point[]): Point[] { + return screenCoordinates.map(this.targetToWorld.bind(this)); + } - public setLineStyles( - isHighlighted: boolean, - isSelected: boolean, - color: string, - lineWidth: number, - dash: number[] = [], - ) {} + public setLineStyles( + isHighlighted: boolean, + isSelected: boolean, + color: string, + lineWidth: number, + dash: number[] = [] + ) {} - public setFillStyles(fillColor: string) {} + public setFillStyles(fillColor: string) {} - public clear() {} + public clear() {} - /** - * Draws a line from startPoint to endPoint and auto converts to screen space first - * @param worldStartPoint - * @param worldEndPoint - */ - public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {} + /** + * Draws a line from startPoint to endPoint and auto converts to screen space first + * @param worldStartPoint + * @param worldEndPoint + */ + public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {} - /** - * Needs to be public to draw UI that is zoom independent, like snap point indicators - * @param screenStartPoint - * @param screenEndPoint - */ - public drawLineScreen( - screenStartPoint: Point, - screenEndPoint: Point, - ): void {} + /** + * Needs to be public to draw UI that is zoom independent, like snap point indicators + * @param screenStartPoint + * @param screenEndPoint + */ + public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {} - /** - * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI - * @param centerPoint - * @param radius - * @param startAngle - * @param endAngle - * @param counterClockWise - */ - public drawArc( - centerPoint: Point, - radius: number, - startAngle: number, - endAngle: number, - counterClockWise: boolean, - ) {} + /** + * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI + * @param centerPoint + * @param radius + * @param startAngle + * @param endAngle + * @param counterClockWise + */ + public drawArc( + centerPoint: Point, + radius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean + ) {} - public drawArcScreen( - screenCenterPoint: Point, - screenRadius: number, - startAngle: number, - endAngle: number, - counterClockWise: boolean, - ) {} + public drawArcScreen( + screenCenterPoint: Point, + screenRadius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean + ) {} - /** - * Draw some text at the base location - * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label - * @param label - * @param basePoint - * @param options - */ - public drawText( - label: string, - basePoint: Point, - options: Partial<{ - textDirection?: Vector; - textAlign: 'left' | 'center' | 'right'; - textColor: string; - fontSize: number; - fontFamily: string; - }> = {}, - ): void {} + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label + * @param label + * @param basePoint + * @param options + */ + public drawText( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {} + ): void {} - /** - * Draw some text at the base location - * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text - * @param label - * @param basePoint - * @param options - */ - public drawTextScreen( - label: string, - basePoint: Point, - options: Partial<{ - textDirection?: Vector; - textAlign: 'left' | 'center' | 'right'; - textColor: string; - fontSize: number; - fontFamily: string; - }> = {}, - ): void {} + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text + * @param label + * @param basePoint + * @param options + */ + public drawTextScreen( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {} + ): void {} - /** - * Draw an image to the canvas using world coordinates - * @param imageElement - * @param xMin - * @param yMin - * @param width - * @param height - * @param angle - */ - public drawImage( - imageElement: HTMLImageElement, - xMin: number, - yMin: number, - width: number, - height: number, - angle: number, - ): void {} + /** + * Draw an image to the canvas using world coordinates + * @param imageElement + * @param xMin + * @param yMin + * @param width + * @param height + * @param angle + */ + public drawImage( + imageElement: HTMLImageElement, + xMin: number, + yMin: number, + width: number, + height: number, + angle: number + ): void {} - public fillRect( - xMin: number, - yMin: number, - width: number, - height: number, - color: string, - ) {} + public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) {} - /** - * Fill rectangle with color, but interpret the provided coordinates as screen coordinates - * @param xMin - * @param yMin - * @param width - * @param height - * @param color - */ - public fillRectScreen( - xMin: number, - yMin: number, - width: number, - height: number, - color: string, - ) {} + /** + * Fill rectangle with color, but interpret the provided coordinates as screen coordinates + * @param xMin + * @param yMin + * @param width + * @param height + * @param color + */ + public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {} - /** - * Fill polygon with color - * @param points - */ - public fillPolygon(...points: Point[]) {} + /** + * Fill polygon with color + * @param points + */ + public fillPolygon(...points: Point[]) {} } diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json index ad61f1bd..6c176171 100644 --- a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json @@ -1,163 +1,163 @@ { - "title": "record eraser 2", - "selectorAttribute": "data-id", - "steps": [ + "title": "record eraser 2", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ { - "type": "setViewport", - "width": 1278, - "height": 1430, - "deviceScaleFactor": 1, - "isMobile": false, - "hasTouch": false, - "isLandscape": false - }, - { - "type": "navigate", - "url": "http://localhost:5173/", - "assertedEvents": [ - { - "type": "navigation", - "url": "http://localhost:5173/", - "title": "Open WebCAD" - } - ] - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 148, - "offsetX": 473 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 698, - "offsetX": 473 - }, - { - "type": "keyDown", - "target": "main", - "key": "c" - }, - { - "type": "keyUp", - "key": "c", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "i" - }, - { - "type": "keyUp", - "key": "i", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "r" - }, - { - "type": "keyUp", - "key": "r", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "c" - }, - { - "type": "keyUp", - "key": "c", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "l" - }, - { - "type": "keyUp", - "key": "l", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "e" - }, - { - "type": "keyUp", - "key": "e", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "Enter" - }, - { - "type": "keyUp", - "key": "Enter", - "target": "main" - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 402, - "offsetX": 266 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 441, - "offsetX": 542 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='delete-segment-button'] path"], - ["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"], - ["pierce/[data-id='delete-segment-button'] path"], - ["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"] - ], - "offsetY": 15, - "offsetX": 9 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 291, - "offsetX": 524 + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" } - ] + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 148, + "offsetX": 473 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 698, + "offsetX": 473 + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "i" + }, + { + "type": "keyUp", + "key": "i", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "r" + }, + { + "type": "keyUp", + "key": "r", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "l" + }, + { + "type": "keyUp", + "key": "l", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "e" + }, + { + "type": "keyUp", + "key": "e", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "Enter" + }, + { + "type": "keyUp", + "key": "Enter", + "target": "main" + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 402, + "offsetX": 266 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 441, + "offsetX": 542 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='delete-segment-button'] path"], + ["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"], + ["pierce/[data-id='delete-segment-button'] path"], + ["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"] + ], + "offsetY": 15, + "offsetX": 9 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 291, + "offsetX": 524 + } + ] } diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts index 237c6a41..3a7c898a 100644 --- a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts @@ -1,13 +1,13 @@ -import {Point} from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */ -import {expect, test} from 'vitest'; -import type {ArcJsonData} from '../../../src/entities/ArcEntity'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import type {LineJsonData} from '../../../src/entities/LineEntity'; -import {pointDistance} from '../../../src/helpers/distance-between-points'; -import {getEntities} from '../../../src/state'; -import {initApplication} from '../../helpers/init-application'; -import {replayRecording} from '../../helpers/replay-recording'; -import {CANVAS_HEIGHT} from "../../helpers/tests.consts"; +import { Point } from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, test } from 'vitest'; +import type { ArcJsonData } from '../../../src/entities/ArcEntity'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import type { LineJsonData } from '../../../src/entities/LineEntity'; +import { pointDistance } from '../../../src/helpers/distance-between-points'; +import { getEntities } from '../../../src/state'; +import { initApplication } from '../../helpers/init-application'; +import { replayRecording } from '../../helpers/replay-recording'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; import eraserRecording from './eraser.recording.json'; test('Draw circle and line and erase part of circle', async () => { diff --git a/resources/data_global_contours/convert_to_gpkg.py b/resources/data_global_contours/convert_to_gpkg.py index d266eab1..0fb28ce0 100644 --- a/resources/data_global_contours/convert_to_gpkg.py +++ b/resources/data_global_contours/convert_to_gpkg.py @@ -5,12 +5,14 @@ 실행 전 가상환경(venv) 터미널에서 다음 라이브러리를 설치해 주세요: pip install geopandas pyogrio """ + import sys from pathlib import Path import time try: import geopandas as gpd + # fiona 대신 현재 설치된 고속 pyogrio 엔진을 검증 및 사용합니다. import pyogrio except ImportError: @@ -19,43 +21,48 @@ except ImportError: print(">>> pip install geopandas pyogrio") sys.exit(1) + def convert_shp_to_gpkg(): current_dir = Path(__file__).resolve().parent shp_files = sorted(list(current_dir.glob("TN_CTRLN*.shp"))) - + if not shp_files: - print(f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다.") + print( + f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다." + ) return - + gpkg_output_path = current_dir / "national_contours.gpkg" print(f"-> 총 {len(shp_files)}개의 등고선 SHP 파일이 감지되었습니다.") print(f"-> 변환 시작 (저장 경로: {gpkg_output_path})") - + start_time = time.time() - + # 첫 번째 파일 처리 (새 GeoPackage 파일 생성) first_shp = shp_files[0] print(f"\n[1/{len(shp_files)}] {first_shp.name} 읽는 중...") try: # GeoPandas를 이용해 shapefile 로드 (엔진으로 pyogrio 명시하여 고속 로드) gdf = gpd.read_file(first_shp, encoding="cp949", engine="pyogrio") - + # 속성 필드명 표준화 (소문자로 통일하고 cont_val / elev 필드가 있으면 elevation으로 통일) gdf.columns = [col.lower() for col in gdf.columns] for elev_col in ["cont_val", "elev_val", "elevation"]: if elev_col in gdf.columns: gdf["elevation"] = gdf[elev_col].astype(float) break - + # 필요한 필드만 최소한으로 남겨 용량 최소화 keep_cols = ["geometry", "elevation"] if "elevation" in gdf.columns else ["geometry"] gdf = gdf[keep_cols] - + # GeoPackage 파일로 쓰기 (고속 pyogrio 엔진 및 spatial_index 생성 활성화) print(" -> GeoPackage 초기 생성 및 쓰기 중...") - gdf.to_file(gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio") + gdf.to_file( + gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio" + ) print(f" -> 완료 (레코드 수: {len(gdf)}개)") - + except Exception as e: print(f" -> [에러] 첫 번째 파일 처리 중 오류 발생: {e}") return @@ -65,26 +72,35 @@ def convert_shp_to_gpkg(): print(f"\n[{idx}/{len(shp_files)}] {shp_path.name} 읽는 중...") try: gdf_append = gpd.read_file(shp_path, encoding="cp949", engine="pyogrio") - + # 컬럼 표준화 gdf_append.columns = [col.lower() for col in gdf_append.columns] for elev_col in ["cont_val", "elev_val", "elevation"]: if elev_col in gdf_append.columns: gdf_append["elevation"] = gdf_append[elev_col].astype(float) break - - keep_cols = ["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"] + + keep_cols = ( + ["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"] + ) gdf_append = gdf_append[keep_cols] - + # 기존 gpkg 파일에 이어쓰기 (append mode, pyogrio 엔진 사용) print(" -> GeoPackage에 데이터 이어붙이는 중...") - gdf_append.to_file(gpkg_output_path, layer="contours", driver="GPKG", mode="a", spatial_index=True, engine="pyogrio") + gdf_append.to_file( + gpkg_output_path, + layer="contours", + driver="GPKG", + mode="a", + spatial_index=True, + engine="pyogrio", + ) print(f" -> 완료 (레코드 수: {len(gdf_append)}개)") - + except Exception as e: print(f" -> [에러] {shp_path.name} 파일 처리 중 오류 발생: {e}. 계속 진행합니다.") continue - + end_time = time.time() elapsed = end_time - start_time print("\n==================================================") @@ -92,5 +108,6 @@ def convert_shp_to_gpkg(): print(f"★ 파일 위치: {gpkg_output_path}") print("==================================================") + if __name__ == "__main__": convert_shp_to_gpkg() diff --git a/resources/knowledge/original/_pipeline/build_srcmap.py b/resources/knowledge/original/_pipeline/build_srcmap.py index 8b8dd121..f7e34d86 100644 --- a/resources/knowledge/original/_pipeline/build_srcmap.py +++ b/resources/knowledge/original/_pipeline/build_srcmap.py @@ -1,69 +1,86 @@ # -*- coding: utf-8 -*- """lawapi.json에서 정확명 매칭 행을 뽑아 시행일/개정일 맵 생성 + 수동 보정.""" + import json from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + OUT = Path(str(DATA_DIR)) api = json.load(open(OUT / "lawapi.json", encoding="utf-8")) # 목록상 명칭 -> API 조회명 (다르면 매핑) Q = { - "산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률", - "산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령", - "산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙", - "산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률", - "산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령", - "산림보호법": "산림보호법", "산지관리법": "산지관리법", - "자연환경보전법": "자연환경보전법", "자연재해대책법": "자연재해대책법", - "환경영향평가법": "환경영향평가법", - "보조금 관리에 관한 법률": "보조금 관리에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령", - "지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "도로법": "도로법", "농어촌도로정비법": "농어촌도로 정비법", - "국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률", - "도로명주소법": "도로명주소법", "도로명주소법 시행령": "도로명주소법 시행령", - "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", - "산업안전보건법": "산업안전보건법", "산업재해보상보험법": "산업재해보상보험법", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", - "고용보험법 시행령": "고용보험법 시행령", - "국민건강보험법": "국민건강보험법", "국민연금법": "국민연금법", - "노인장기요양보험법": "노인장기요양보험법", "노인장기요양보험법 시행령": "노인장기요양보험법 시행령", - "부가가치세법": "부가가치세법", "근로기준법": "근로기준법", - "산업표준화법": "산업표준화법", "전자서명법": "전자서명법", - "공동주택관리법": "공동주택관리법", - "임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정", - "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정", - "사업종류별 산재보험료율 고시": "사업종류별 산재보험료율", - "건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준", - "(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준", - "엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준", + "산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률", + "산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령", + "산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙", + "산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률", + "산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령", + "산림보호법": "산림보호법", + "산지관리법": "산지관리법", + "자연환경보전법": "자연환경보전법", + "자연재해대책법": "자연재해대책법", + "환경영향평가법": "환경영향평가법", + "보조금 관리에 관한 법률": "보조금 관리에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령", + "지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률", + "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", + "도로법": "도로법", + "농어촌도로정비법": "농어촌도로 정비법", + "국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률", + "도로명주소법": "도로명주소법", + "도로명주소법 시행령": "도로명주소법 시행령", + "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", + "산업안전보건법": "산업안전보건법", + "산업재해보상보험법": "산업재해보상보험법", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", + "고용보험법 시행령": "고용보험법 시행령", + "국민건강보험법": "국민건강보험법", + "국민연금법": "국민연금법", + "노인장기요양보험법": "노인장기요양보험법", + "노인장기요양보험법 시행령": "노인장기요양보험법 시행령", + "부가가치세법": "부가가치세법", + "근로기준법": "근로기준법", + "산업표준화법": "산업표준화법", + "전자서명법": "전자서명법", + "공동주택관리법": "공동주택관리법", + "임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정", + "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정", + "사업종류별 산재보험료율 고시": "사업종류별 산재보험료율", + "건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준", + "(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준", + "엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준", } # 조회명이 목록명과 다른 경우 실제 API 반환명 지정 EXACT = { - "농어촌도로정비법": "농어촌도로 정비법", - "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", - "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정", - "사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율", - "(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준", + "농어촌도로정비법": "농어촌도로 정비법", + "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", + "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정", + "사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율", + "(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준", } SRC_LAW = "국가법령정보센터" @@ -71,61 +88,226 @@ m = {} for name, q in Q.items(): rows = api.get(q, {}).get("rows", []) want = EXACT.get(name, name) - hit = next((r for r in rows if r["명"] == want), None) or next((r for r in rows if r["명"].startswith(want)), None) + hit = next((r for r in rows if r["명"] == want), None) or next( + (r for r in rows if r["명"].startswith(want)), None + ) if not hit: print("!! 미매칭", name, "|", [r["명"] for r in rows][:3]) continue - m[name] = {"시행": hit["시행"], "개정": hit["공포"], "구분": hit["제개정"], - "호수": hit["번호"], "종류": hit["종류"], "출처": SRC_LAW, "실명": hit["명"]} + m[name] = { + "시행": hit["시행"], + "개정": hit["공포"], + "구분": hit["제개정"], + "호수": hit["번호"], + "종류": hit["종류"], + "출처": SRC_LAW, + "실명": hit["명"], + } # ── 2·3차 조회 결과 및 별표·비수록 항목 수동 등록 ── MANUAL = { - "국가균형발전특별법": ("2026.07.01", "2026.03.05", "일부개정", "21447", "법률", SRC_LAW, - "폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」"), - "수치지도 작성 작업규칙": ("2015.06.04", "2015.06.04", "일부개정", "00209", "국토교통부령", SRC_LAW, ""), - "재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ("2025.11.28", "2025.11.27", "일부개정", "35875", "대통령령", SRC_LAW, - "「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)"), - "임도 품셈 적용기준 / 임도표준품셈": ("2026.01.01", "2025.11.26", "전부개정", "2025-82", "고시", SRC_LAW, - "현 「산림사업 표준품셈」(산림청 고시)"), - "건설공사 감독자 업무 지침": ("2026.07.08", "2026.07.08", "일부개정", "2026-360", "고시", SRC_LAW, - "현 「건설공사 사업관리방식 검토기준 및 업무수행지침」"), - "공사장의 비산분진 발생원 시설관리기준": ("2026.07.15", "2026.07.15", "일부개정", "00049", "환경부령", SRC_LAW, - "「대기환경보전법 시행규칙」 별표에 수록"), - "국가지점번호판 규격 등 고시": ("2024.07.05", "2024.07.05", "제정", "2024-56", "고시", SRC_LAW, - "「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」"), - "국가지점번호 부여기준 및 방법": ("2012.12.18", "2012.12.12", "제정", "2012-55", "고시", SRC_LAW, - "「국가지점번호 기준점 고시」"), - "입찰유의서(계약예규)": ("2025.12.31", "2025.12.31", "일부개정", "", "계약예규", SRC_LAW, - "「(계약예규) 공사입찰유의서」"), - "콘크리트 표준시방서": ("2025.01.05", "2024.12.30", "일부개정", "2025-879", "고시", "국가건설기준센터", - "KCS 14 20 00 (국토교통부 고시로도 수록)"), - "도로공사 표준시방서": ("2023.01.12", "2023.01.06", "일부개정", "2023-907", "고시", "국가건설기준센터", - "KCS 44 00 00"), - "토목공사 표준시방서": ("2023.01.25", "2023.01.19", "일부개정", "2023-48", "고시", "국가건설기준센터", - "현 KCS 10 00 00 공통공사 표준시방서로 재편"), - "건설공사 표준시방서": ("2018.08.09", "2018.08.03", "제정", "2018-468", "고시", "국가건설기준센터", - "건설기준 코드(KDS/KCS) 체계로 통합"), - "건설공사 비탈면 표준시방서": ("", "", "", "", "", "국가건설기준센터", - "KCS 11 70 00 비탈면 (법령정보센터 미수록)"), - "임도시설공사 표준시방서": ("", "", "", "", "", "산림청", - "법령정보센터·건설기준센터 모두 미수록"), - "산림관리기반시설의 설계 및 시설기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표2에 수록"), - "산림관리기반시설의 범위 및 기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표1에 수록"), - "산림관리기반시설의 타당성평가 항목별 기준 및 방법": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표1의2에 수록"), - "지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ("", "", "", "", "지침", "산림청", - "법령정보센터 미수록"), - "중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"), - "예산편성기준": ("", "", "", "", "지침", "기획재정부", - "「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포"), - "토목공사원가계산 제비율 적용기준": ("", "", "", "", "기준", "조달청", - "법령정보센터 미수록, 조달청 연간 발표"), + "국가균형발전특별법": ( + "2026.07.01", + "2026.03.05", + "일부개정", + "21447", + "법률", + SRC_LAW, + "폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」", + ), + "수치지도 작성 작업규칙": ( + "2015.06.04", + "2015.06.04", + "일부개정", + "00209", + "국토교통부령", + SRC_LAW, + "", + ), + "재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ( + "2025.11.28", + "2025.11.27", + "일부개정", + "35875", + "대통령령", + SRC_LAW, + "「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)", + ), + "임도 품셈 적용기준 / 임도표준품셈": ( + "2026.01.01", + "2025.11.26", + "전부개정", + "2025-82", + "고시", + SRC_LAW, + "현 「산림사업 표준품셈」(산림청 고시)", + ), + "건설공사 감독자 업무 지침": ( + "2026.07.08", + "2026.07.08", + "일부개정", + "2026-360", + "고시", + SRC_LAW, + "현 「건설공사 사업관리방식 검토기준 및 업무수행지침」", + ), + "공사장의 비산분진 발생원 시설관리기준": ( + "2026.07.15", + "2026.07.15", + "일부개정", + "00049", + "환경부령", + SRC_LAW, + "「대기환경보전법 시행규칙」 별표에 수록", + ), + "국가지점번호판 규격 등 고시": ( + "2024.07.05", + "2024.07.05", + "제정", + "2024-56", + "고시", + SRC_LAW, + "「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」", + ), + "국가지점번호 부여기준 및 방법": ( + "2012.12.18", + "2012.12.12", + "제정", + "2012-55", + "고시", + SRC_LAW, + "「국가지점번호 기준점 고시」", + ), + "입찰유의서(계약예규)": ( + "2025.12.31", + "2025.12.31", + "일부개정", + "", + "계약예규", + SRC_LAW, + "「(계약예규) 공사입찰유의서」", + ), + "콘크리트 표준시방서": ( + "2025.01.05", + "2024.12.30", + "일부개정", + "2025-879", + "고시", + "국가건설기준센터", + "KCS 14 20 00 (국토교통부 고시로도 수록)", + ), + "도로공사 표준시방서": ( + "2023.01.12", + "2023.01.06", + "일부개정", + "2023-907", + "고시", + "국가건설기준센터", + "KCS 44 00 00", + ), + "토목공사 표준시방서": ( + "2023.01.25", + "2023.01.19", + "일부개정", + "2023-48", + "고시", + "국가건설기준센터", + "현 KCS 10 00 00 공통공사 표준시방서로 재편", + ), + "건설공사 표준시방서": ( + "2018.08.09", + "2018.08.03", + "제정", + "2018-468", + "고시", + "국가건설기준센터", + "건설기준 코드(KDS/KCS) 체계로 통합", + ), + "건설공사 비탈면 표준시방서": ( + "", + "", + "", + "", + "", + "국가건설기준센터", + "KCS 11 70 00 비탈면 (법령정보센터 미수록)", + ), + "임도시설공사 표준시방서": ( + "", + "", + "", + "", + "", + "산림청", + "법령정보센터·건설기준센터 모두 미수록", + ), + "산림관리기반시설의 설계 및 시설기준": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표2에 수록", + ), + "산림관리기반시설의 범위 및 기준": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표1에 수록", + ), + "산림관리기반시설의 타당성평가 항목별 기준 및 방법": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표1의2에 수록", + ), + "지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ( + "", + "", + "", + "", + "지침", + "산림청", + "법령정보센터 미수록", + ), + "중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"), + "예산편성기준": ( + "", + "", + "", + "", + "지침", + "기획재정부", + "「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포", + ), + "토목공사원가계산 제비율 적용기준": ( + "", + "", + "", + "", + "기준", + "조달청", + "법령정보센터 미수록, 조달청 연간 발표", + ), } for k, v in MANUAL.items(): - m[k] = {"시행": v[0], "개정": v[1], "구분": v[2], "호수": v[3], - "종류": v[4], "출처": v[5], "실명": "", "추가": v[6]} + m[k] = { + "시행": v[0], + "개정": v[1], + "구분": v[2], + "호수": v[3], + "종류": v[4], + "출처": v[5], + "실명": "", + "추가": v[6], + } json.dump(m, open(OUT / "srcmap.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print(f"매핑 {len(m)}건 생성") diff --git a/resources/knowledge/original/_pipeline/check_law.py b/resources/knowledge/original/_pipeline/check_law.py index 3248283e..7029f246 100644 --- a/resources/knowledge/original/_pipeline/check_law.py +++ b/resources/knowledge/original/_pipeline/check_law.py @@ -1,30 +1,42 @@ # -*- coding: utf-8 -*- """국가법령정보센터 Open API로 목록 항목의 보유 여부·시행일·개정일 조회.""" + import json, re, time, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import xml.etree.ElementTree as ET OUT = Path(str(DATA_DIR)) + def api(target, query, display=5): - url = ("https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML" - f"&target={target}&display={display}&query=" + urllib.parse.quote(query)) + url = ( + "https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML" + f"&target={target}&display={display}&query=" + urllib.parse.quote(query) + ) for _ in range(3): try: with urllib.request.urlopen(url, timeout=25) as r: @@ -33,6 +45,7 @@ def api(target, query, display=5): time.sleep(1.2) return None + def txt(node, *names): for n in names: el = node.find(n) @@ -40,51 +53,75 @@ def txt(node, *names): return el.text.strip() return "" + def ymd(s): s = re.sub(r"\D", "", s or "") return f"{s[:4]}.{s[4:6]}.{s[6:8]}" if len(s) == 8 else "" + LAWS = [ - "산림자원의 조성 및 관리에 관한 법률", "산림자원의 조성 및 관리에 관한 법률 시행령", - "산림자원의 조성 및 관리에 관한 법률 시행규칙", "산림기술 진흥 및 관리에 관한 법률", - "산림기술 진흥 및 관리에 관한 법률 시행령", "산림보호법", "산지관리법", - "자연환경보전법", "자연재해대책법", "환경영향평가법", "국가균형발전특별법", - "보조금 관리에 관한 법률", "국가를 당사자로 하는 계약에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률 시행령", - "지방자치단체를 당사자로 하는 계약에 관한 법률", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "도로법", "농어촌도로 정비법", "국토의 계획 및 이용에 관한 법률", - "도로명주소법", "도로명주소법 시행령", "공간정보의 구축 및 관리 등에 관한 법률", - "산업안전보건법", "산업재해보상보험법", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", - "고용보험법 시행령", "국민건강보험법", "국민연금법", "노인장기요양보험법", - "노인장기요양보험법 시행령", "부가가치세법", "근로기준법", "산업표준화법", - "전자서명법", "공동주택관리법", + "산림자원의 조성 및 관리에 관한 법률", + "산림자원의 조성 및 관리에 관한 법률 시행령", + "산림자원의 조성 및 관리에 관한 법률 시행규칙", + "산림기술 진흥 및 관리에 관한 법률", + "산림기술 진흥 및 관리에 관한 법률 시행령", + "산림보호법", + "산지관리법", + "자연환경보전법", + "자연재해대책법", + "환경영향평가법", + "국가균형발전특별법", + "보조금 관리에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률 시행령", + "지방자치단체를 당사자로 하는 계약에 관한 법률", + "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", + "도로법", + "농어촌도로 정비법", + "국토의 계획 및 이용에 관한 법률", + "도로명주소법", + "도로명주소법 시행령", + "공간정보의 구축 및 관리 등에 관한 법률", + "산업안전보건법", + "산업재해보상보험법", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", + "고용보험법 시행령", + "국민건강보험법", + "국민연금법", + "노인장기요양보험법", + "노인장기요양보험법 시행령", + "부가가치세법", + "근로기준법", + "산업표준화법", + "전자서명법", + "공동주택관리법", ] RULES = [ - "임도설치 및 관리 등에 관한 규정", - "산림관리기반시설의 설계 및 시설기준", - "산림관리기반시설의 범위 및 기준", - "산림관리기반시설의 타당성평가", - "임도 품셈", - "자연휴양림업무 처리지침", - "중기운용관리", - "훈령·예규 등의 발령 및 관리에 관한 규정", - "국가지점번호", - "재난구호 및 재난복구 비용 부담기준 등에 관한 규정", - "사업종류별 산재보험료율", - "건설업 산업안전보건관리비 계상 및 사용기준", - "사회보험의 보험료 적용기준", - "엔지니어링사업대가의 기준", - "예산안 편성", - "토목공사원가계산 제비율", - "입찰유의서", - "수치지도 작성 작업규칙", - "비산분진 발생원 시설관리기준", - "건설공사 감독자 업무 지침", - "콘크리트 표준시방서", "도로공사 표준시방서", "임도시설공사 표준시방서", + "임도설치 및 관리 등에 관한 규정", + "산림관리기반시설의 설계 및 시설기준", + "산림관리기반시설의 범위 및 기준", + "산림관리기반시설의 타당성평가", + "임도 품셈", + "자연휴양림업무 처리지침", + "중기운용관리", + "훈령·예규 등의 발령 및 관리에 관한 규정", + "국가지점번호", + "재난구호 및 재난복구 비용 부담기준 등에 관한 규정", + "사업종류별 산재보험료율", + "건설업 산업안전보건관리비 계상 및 사용기준", + "사회보험의 보험료 적용기준", + "엔지니어링사업대가의 기준", + "예산안 편성", + "토목공사원가계산 제비율", + "입찰유의서", + "수치지도 작성 작업규칙", + "비산분진 발생원 시설관리기준", + "건설공사 감독자 업무 지침", + "콘크리트 표준시방서", + "도로공사 표준시방서", + "임도시설공사 표준시방서", ] res = {} @@ -94,18 +131,23 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R rows = [] if root is not None: for node in root.findall("law") + root.findall("admrul"): - rows.append({ - "명": txt(node, "법령명한글", "행정규칙명"), - "약칭": txt(node, "법령약칭명"), - "종류": txt(node, "법령구분명", "행정규칙종류"), - "부처": txt(node, "소관부처명", "소관부처명"), - "공포": ymd(txt(node, "공포일자", "발령일자")), - "시행": ymd(txt(node, "시행일자")), - "제개정": txt(node, "제개정구분명", "제개정구분코드"), - "번호": txt(node, "공포번호", "발령번호"), - }) + rows.append( + { + "명": txt(node, "법령명한글", "행정규칙명"), + "약칭": txt(node, "법령약칭명"), + "종류": txt(node, "법령구분명", "행정규칙종류"), + "부처": txt(node, "소관부처명", "소관부처명"), + "공포": ymd(txt(node, "공포일자", "발령일자")), + "시행": ymd(txt(node, "시행일자")), + "제개정": txt(node, "제개정구분명", "제개정구분코드"), + "번호": txt(node, "공포번호", "발령번호"), + } + ) res[q] = {"grp": grp, "target": target, "rows": rows} - print(f"[{grp}] {q} -> {len(rows)}건" + (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else "")) + print( + f"[{grp}] {q} -> {len(rows)}건" + + (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else "") + ) time.sleep(0.35) json.dump(res, open(OUT / "lawapi.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) diff --git a/resources/knowledge/original/_pipeline/collect_cost_sources.py b/resources/knowledge/original/_pipeline/collect_cost_sources.py index 5dbe69fa..c5163019 100644 --- a/resources/knowledge/original/_pipeline/collect_cost_sources.py +++ b/resources/knowledge/original/_pipeline/collect_cost_sources.py @@ -19,6 +19,7 @@ 값형(4~6)은 원본 스냅샷을 폴더에 보존하고, 프로그램용 데이터셋은 여기서 별도 추출해 구성한다 (2026-08-14 사용자 결정). 표준시장단가는 수집 제외 (100억 미만 공사 미적용·품셈 방식과 별도 트랙). """ + import csv import json import os @@ -36,18 +37,30 @@ UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} # ── 반기 갱신 대상 URL (2026 상반기/2026년판 기준) ── DOC_SOURCES = [ # (폴더, 파일명, URL, referer) - ("노임단가_건설업_대한건설협회", "2026상반기_건설업_임금실태조사_대한건설협회.pdf", - "https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf", - "https://www.cak.or.kr/lay1/S1T16C41/sublink.do"), - ("노임단가_제조업_중소기업중앙회", "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf", - None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용 - "https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372"), - ("건설공사_표준품셈", "2026년_건설공사_표준품셈.pdf", - "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1", - "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"), - ("건설공사_표준품셈", "2026년_건설공사_표준품셈_개정사항.pdf", - "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2", - "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"), + ( + "노임단가_건설업_대한건설협회", + "2026상반기_건설업_임금실태조사_대한건설협회.pdf", + "https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf", + "https://www.cak.or.kr/lay1/S1T16C41/sublink.do", + ), + ( + "노임단가_제조업_중소기업중앙회", + "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf", + None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용 + "https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372", + ), + ( + "건설공사_표준품셈", + "2026년_건설공사_표준품셈.pdf", + "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1", + "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1", + ), + ( + "건설공사_표준품셈", + "2026년_건설공사_표준품셈_개정사항.pdf", + "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2", + "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1", + ), ] G2B_OPS = [ @@ -93,7 +106,11 @@ def collect_docs(): continue url = "https://www.kbiz.or.kr" + links[0].replace("&", "&") data = fetch(url, referer) - if not data[:4] == b"%PDF" and not data[:4] == b"PK\x03\x04" and not data[:8].startswith(b"\xd0\xcf\x11\xe0"): + if ( + not data[:4] == b"%PDF" + and not data[:4] == b"PK\x03\x04" + and not data[:8].startswith(b"\xd0\xcf\x11\xe0") + ): print(f"FAIL {fname}: PDF/HWP 아님 ({data[:8].hex()}) — URL 갱신 필요") continue dest.write_bytes(data) @@ -109,13 +126,22 @@ def collect_values(): ecos = read_key(r"ECOS[^`]*\n- 인증키: `([^`]+)`", "ECOS") year = today[:4] rows = [] - for code, name in [("0000001", "원/미국달러"), ("0000002", "원/일본엔100"), - ("0000003", "원/유로"), ("0000012", "원/영국파운드"), ("0000053", "원/위안")]: - url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/" - f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}") + for code, name in [ + ("0000001", "원/미국달러"), + ("0000002", "원/일본엔100"), + ("0000003", "원/유로"), + ("0000012", "원/영국파운드"), + ("0000053", "원/위안"), + ]: + url = ( + f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/" + f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}" + ) j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace")) for r in j.get("StatisticSearch", {}).get("row", []): - rows.append({"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]}) + rows.append( + {"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]} + ) out = BASE / "환율_한국은행ECOS" / f"환율_일별_{year}0101_{today}.csv" out.parent.mkdir(exist_ok=True) with open(out, "w", newline="", encoding="utf-8-sig") as f: @@ -127,11 +153,18 @@ def collect_values(): # 유가: 오늘 전국 평균 + 유종별 최근 7일 opinet = read_key(r"오피넷[^`]*`([^`]+)`", "오피넷") data = {"수집일": today} - j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60)) + j = json.loads( + fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60) + ) data["전국평균"] = j.get("RESULT", {}).get("OIL", []) data["최근7일"] = {} for prod in ["B027", "D047"]: # 휘발유, 자동차용경유 - j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", timeout=60)) + j = json.loads( + fetch( + f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", + timeout=60, + ) + ) data["최근7일"][prod] = j.get("RESULT", {}).get("OIL", []) out = BASE / "유가_오피넷" / f"유가_전국평균_{today}.json" out.parent.mkdir(exist_ok=True) @@ -146,8 +179,10 @@ def collect_snapshot(outdir=None): for op, label in G2B_OPS: page, got, total = 1, 0, 1 while got < total: - url = (f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}" - f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json") + url = ( + f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}" + f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json" + ) j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace")) body = j.get("response", {}).get("body", {}) total = int(body.get("totalCount") or 0) @@ -173,7 +208,8 @@ def collect_snapshot(outdir=None): w.writeheader() w.writerows(rows) (outp / f"나라장터_시설공통자재_{today}.json").write_text( - json.dumps(rows, ensure_ascii=False), encoding="utf-8") + json.dumps(rows, ensure_ascii=False), encoding="utf-8" + ) print(f"saved {out.name} ({len(rows):,} rows, +json)") diff --git a/resources/knowledge/original/_pipeline/extract_zip.py b/resources/knowledge/original/_pipeline/extract_zip.py index 43aa0b6f..934af6d7 100644 --- a/resources/knowledge/original/_pipeline/extract_zip.py +++ b/resources/knowledge/original/_pipeline/extract_zip.py @@ -4,6 +4,7 @@ - zip 내 CP949(EUC-KR) 파일명 mojibake를 복원해 `첨부/[zip]<이름>/` 에 해제 - 내부 HWP/HWPX → md 변환(hwpx_text.to_md / hwp5_to_md) """ + import re, sys, zipfile from pathlib import Path @@ -12,6 +13,7 @@ import hwpx_text ROOT = Path(__file__).resolve().parent.parent + def fixname(n): """zip 엔트리명 CP437 mojibake → CP949 복원.""" try: @@ -19,9 +21,11 @@ def fixname(n): except Exception: return n + def safe_part(s): return re.sub(r'[:*?"<>|]', "_", s).strip() + def extract_one(zip_path): zf = zipfile.ZipFile(zip_path) dest = zip_path.parent / ("[압축] " + zip_path.stem) @@ -38,6 +42,7 @@ def extract_one(zip_path): n += 1 return dest, n + def convert_dir(folder): ok = fail = 0 for f in sorted(folder.rglob("*")): @@ -46,9 +51,9 @@ def convert_dir(folder): md = f.with_suffix(".md") try: b = f.read_bytes()[:4] - if b[:2] == b"PK": # HWPX + if b[:2] == b"PK": # HWPX text = hwpx_text.to_md(f) - elif b.hex() == "d0cf11e0": # 구형 HWP + elif b.hex() == "d0cf11e0": # 구형 HWP text = hwpx_text.hwp5_to_md(f) else: fail += 1 @@ -60,6 +65,7 @@ def convert_dir(folder): fail += 1 return ok, fail + if __name__ == "__main__": zips = [Path(a) for a in sys.argv[1:]] or list(ROOT.rglob("첨부/*.zip")) for z in zips: diff --git a/resources/knowledge/original/_pipeline/fix_box_tables.py b/resources/knowledge/original/_pipeline/fix_box_tables.py index d10cc2d0..090b7a81 100644 --- a/resources/knowledge/original/_pipeline/fix_box_tables.py +++ b/resources/knowledge/original/_pipeline/fix_box_tables.py @@ -4,28 +4,34 @@ 법령 조문의 안에 있던 박스 드로잉 표가 이미지 로컬화 후 텍스트로 남는데, │로 열을 구분하므로 md 표로 복원한다. 각 표에는 대응 ![그림]도 이미 있다. """ + import re, sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent BORDER = set("┌┬┐├┼┤└┴┘─━┏┳┓┣╋┫┗┻┛│┃ \t") VBAR = "│┃|" -QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> ' +QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> ' + def unq(l): return QP.sub("", l) + def is_border(l): s = unq(l).strip() return bool(s) and all(c in BORDER for c in s) and any(c in "─━┼┬┴┌┐└┘├┤" for c in s) + def is_data(l): return any(c in "│┃" for c in unq(l)) + def split_cells(l): s = unq(l).strip().strip("│┃") return [c.strip() for c in re.split(r"[│┃]", s)] + def convert_block(lines): rows = [split_cells(l) for l in lines if is_data(l)] rows = [r for r in rows if any(c for c in r)] @@ -36,12 +42,12 @@ def convert_block(lines): return None rows = [r + [""] * (w - len(r)) for r in rows] esc = lambda c: c.replace("|", "\\|") - out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", - "|" + "|".join(["---"] * w) + "|"] + out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"] for r in rows[1:]: out.append("| " + " | ".join(esc(c) for c in r) + " |") return out + def fix(md_path): lines = md_path.read_text(encoding="utf-8").split("\n") out = [] @@ -51,14 +57,20 @@ def fix(md_path): while i < n: if lines[i].lstrip().startswith("```"): infence = not infence - out.append(lines[i]); i += 1 + out.append(lines[i]) + i += 1 continue # 박스표 블록 시작: border 또는 data(│ 포함) 연속 (펜스 밖에서만) - if not infence and (is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))): + if not infence and ( + is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|")) + ): j = i block = [] - while j < n and (is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))): - block.append(lines[j]); j += 1 + while j < n and ( + is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|")) + ): + block.append(lines[j]) + j += 1 data_rows = [b for b in block if is_data(b)] md = convert_block(block) # 열이 일정한 진짜 표만 md 표로. 아니면(수식 등) 코드펜스로 정렬 보존. @@ -75,11 +87,13 @@ def fix(md_path): changed += 1 i = j continue - out.append(lines[i]); i += 1 + out.append(lines[i]) + i += 1 if changed: md_path.write_text("\n".join(out), encoding="utf-8") return changed + if __name__ == "__main__": total = 0 for md in ROOT.rglob("*.md"): diff --git a/resources/knowledge/original/_pipeline/fix_law_images.py b/resources/knowledge/original/_pipeline/fix_law_images.py index 0bea69f3..b6b520ca 100644 --- a/resources/knowledge/original/_pipeline/fix_law_images.py +++ b/resources/knowledge/original/_pipeline/fix_law_images.py @@ -4,17 +4,25 @@ 법령 XML 조문·개정문에 인라인으로 박힌 수식·그림 이미지 처리. 이미지는 각 명칭 폴더의 pic/ 에 저장하고 md에서 ../pic 상대참조. """ + import re, sys, time, urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent UA = {"User-Agent": "Mozilla/5.0"} # src="URL" 형식과 id="flSeq" 형식(내부 이미지) 모두 처리 -IMG = re.compile(r']*?)/?>') +IMG = re.compile(r"]*?)/?>") SRC = re.compile(r'src="([^"]+)"') IID = re.compile(r'id="(\d+)"') -EXT = {b"\x89PNG": ".png", b"\xff\xd8\xff": ".jpg", b"GIF8": ".gif", - b"BM": ".bmp", b"II*\x00": ".tif", b"MM\x00*": ".tif"} +EXT = { + b"\x89PNG": ".png", + b"\xff\xd8\xff": ".jpg", + b"GIF8": ".gif", + b"BM": ".bmp", + b"II*\x00": ".tif", + b"MM\x00*": ".tif", +} + def sniff(b): for sig, ext in EXT.items(): @@ -22,6 +30,7 @@ def sniff(b): return ext return ".png" + def download(url): u = url.replace("http://", "https://") for _ in range(3): @@ -34,6 +43,7 @@ def download(url): time.sleep(1.5) return None + def fix(md_path): """md 파일: → pic/ 저장 + ![그림](../pic/..) 참조. 폴더는 명칭 폴더의 pic/.""" text = md_path.read_text(encoding="utf-8") @@ -43,6 +53,7 @@ def fix(md_path): folder = md_path.parent picdir = folder / "pic" seq = [0] + def repl(m): attrs = m.group(1) ms = SRC.search(attrs) @@ -61,16 +72,22 @@ def fix(md_path): fn = f"{md_path.stem}_img{seq[0]}{sniff(blob)}" (picdir / fn).write_bytes(blob) return f"![그림]()" + new = IMG.sub(repl, text) # 부칙 등에서 여는 태그와 분리돼 남은 고아 닫는 태그 제거(단독 줄/인용줄 포함) - new = re.sub(r'^>?\s*\s*$', ">", new, flags=re.M) + new = re.sub(r"^>?\s*\s*$", ">", new, flags=re.M) new = new.replace("", "") if new != text: md_path.write_text(new, encoding="utf-8") return seq[0] + if __name__ == "__main__": - mds = list(ROOT.rglob("현행_*.md")) + list(ROOT.rglob("교본시점_*.md")) + list(ROOT.rglob("CHANGELOG.md")) + mds = ( + list(ROOT.rglob("현행_*.md")) + + list(ROOT.rglob("교본시점_*.md")) + + list(ROOT.rglob("CHANGELOG.md")) + ) mds = [m for m in mds if "임도기술교본" not in str(m) and "_pipeline" not in str(m)] tot = 0 for md in sorted(mds): diff --git a/resources/knowledge/original/_pipeline/fix_spacing.py b/resources/knowledge/original/_pipeline/fix_spacing.py index 4204d0ab..ed0f1842 100644 --- a/resources/knowledge/original/_pipeline/fix_spacing.py +++ b/resources/knowledge/original/_pipeline/fix_spacing.py @@ -7,6 +7,7 @@ PDF 표(정상)는 그대로 두고, 공백이 붙어버린 프로즈 줄만 HWP - 별표: 같은 폴더 현행 XML의 별표서식파일링크(HWP)로 원본을 받아 hwp5 추출 - 첨부: 같은 폴더의 .hwp/.hwpx 원본을 사용 """ + import re, sys, time, urllib.request import xml.etree.ElementTree as ET from pathlib import Path @@ -18,11 +19,13 @@ import hwp5_text BASEURL = "https://www.law.go.kr" UA = {"User-Agent": "Mozilla/5.0"} + def nsp(s): # 매칭 키: 공백·특수문자(사설글리프·불릿·문장부호) 제거 → 한글/영숫자만. # HWP와 PDF 추출의 글자 차이(ㅇ·ㆍ·U+F09E 등)를 흡수한다. return re.sub(r"[^가-힣0-9A-Za-z]", "", s) + def spaced_index(hwp_path): """HWP 전체를 하나의 띄어쓰기 문자열로 잇고, 무공백↔원문 위치 맵을 만든다. @@ -52,6 +55,7 @@ def respace(body, spaced, spaced_nsp, pos): # 원문 줄바꿈(문단경계)은 공백으로 return re.sub(r"\s+", " ", seg).strip() + def download(link, dest): url = link if link.startswith("http") else BASEURL + link for _ in range(3): @@ -65,6 +69,7 @@ def download(link, dest): time.sleep(1.5) return False + def byl_link_map(folder): """현행 XML → {별표 stem 접두: HWP링크}. md 파일명과 매칭용.""" xmls = sorted(folder.parent.glob("현행_*.xml")) @@ -83,16 +88,23 @@ def byl_link_map(folder): kind = (b.findtext("별표구분") or "별표").strip() # 파일명 접두(별표/서식/별지)와 XML 구분을 맞춘다 pre = "별표" if kind == "별표" else ("별지" if kind == "별지" else "서식") - key = f"{pre}{num}{('의'+g) if g else ''}" + key = f"{pre}{num}{('의' + g) if g else ''}" link = b.findtext("별표서식파일링크") if link: out[key] = link return out + def restore_line(line, spaced, spaced_nsp, pos): """프로즈 줄이면 띄어쓰기 버전으로 교체. 표 행(|)은 건드리지 않는다.""" st = line.strip() - if not st or st.startswith("|") or st.startswith("#") or st.startswith(">") or st.startswith("!["): + if ( + not st + or st.startswith("|") + or st.startswith("#") + or st.startswith(">") + or st.startswith("![") + ): return line m = re.match(r"^(\s*(?:[-*]\s+|[가-힣]\.\s*|\(\d+\)\s*|\d+\.\s*)?)(.*)$", line) prefix, body = m.group(1), m.group(2) @@ -105,9 +117,10 @@ def restore_line(line, spaced, spaced_nsp, pos): return prefix + sp return line + def fix_file(md, hwp_dir_download=True): text = md.read_text(encoding="utf-8") - folder = md.parent # .../별표 또는 .../첨부 + folder = md.parent # .../별표 또는 .../첨부 stem = md.stem # 소스 HWP 확보 @@ -126,7 +139,7 @@ def fix_file(md, hwp_dir_download=True): if not hwp: return None if hwp.suffix == ".hwpx": - return None # hwpx는 별도(hwpx_text)로 이미 처리 + return None # hwpx는 별도(hwpx_text)로 이미 처리 spaced, pos = spaced_index(hwp) if not spaced: return None @@ -137,5 +150,3 @@ def fix_file(md, hwp_dir_download=True): md.write_text("\n".join(new), encoding="utf-8") return sum(1 for a, b in zip(lines, new) if a != b) return 0 - - diff --git a/resources/knowledge/original/_pipeline/get_attach.py b/resources/knowledge/original/_pipeline/get_attach.py index 37412761..523d2a06 100644 --- a/resources/knowledge/original/_pipeline/get_attach.py +++ b/resources/knowledge/original/_pipeline/get_attach.py @@ -1,31 +1,41 @@ # -*- coding: utf-8 -*- """고시·훈령 본문이 껍데기인 경우 실제 내용이 담긴 첨부파일/별표 원본(HWP)을 내려받는다.""" + import re, time, urllib.request import xml.etree.ElementTree as ET from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR)) UA = {"User-Agent": "Mozilla/5.0"} + def safe(s): return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".") + def get(url, tries=3): url = url.strip().replace("http://law.go.kr", "https://www.law.go.kr") if url.startswith("/"): @@ -40,6 +50,7 @@ def get(url, tries=3): return None time.sleep(2) + tot_att = tot_hwp = 0 for xml in sorted(ROOT.rglob("현행_*.xml")): folder = xml.parent @@ -62,7 +73,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): d.mkdir(parents=True, exist_ok=True) p.write_bytes(blob) tot_att += 1 - print(f" 첨부 {len(blob)//1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True) + print(f" 첨부 {len(blob) // 1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True) time.sleep(0.3) # ── 2) 별표 PDF가 안내문뿐인 경우 HWP 원본 확보 ── @@ -75,17 +86,18 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): continue num = (b.findtext("별표번호") or "0").lstrip("0") or "0" g = (b.findtext("별표가지번호") or "").lstrip("0") - stem = safe(f"{b.findtext('별표구분')}{num}{('의'+g) if g else ''}_{title[:48]}") + stem = safe(f"{b.findtext('별표구분')}{num}{('의' + g) if g else ''}_{title[:48]}") pdf = folder / "별표" / f"{stem}.pdf" if not pdf.exists(): continue try: import pymupdf + t = "".join(pg.get_text() for pg in pymupdf.open(pdf)) except Exception: continue if len(re.sub(r"\s", "", t)) >= 120 and "자세한 내용은" not in t: - continue # 정상 PDF + continue # 정상 PDF hlk = b.findtext("별표서식파일링크") hnm = b.findtext("별표HWP파일명") or f"{stem}.hwp" if not hlk: @@ -100,7 +112,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): continue hp.write_bytes(blob) tot_hwp += 1 - print(f" HWP {len(blob)//1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True) + print(f" HWP {len(blob) // 1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True) time.sleep(0.3) print(f"\n첨부파일 {tot_att}건 / 안내문 별표의 HWP 원본 {tot_hwp}건") diff --git a/resources/knowledge/original/_pipeline/get_kcsc.py b/resources/knowledge/original/_pipeline/get_kcsc.py index ac9466ee..6b75aced 100644 --- a/resources/knowledge/original/_pipeline/get_kcsc.py +++ b/resources/knowledge/original/_pipeline/get_kcsc.py @@ -5,31 +5,40 @@ CodeList로 전체 코드를 받고, 대상 KCS 코드의 CodeViewer 본문을 표준시방서/<명칭>/KCS/<코드>_<이름>.md 로 저장한다. API Key는 keyfile(_kcsc_key.txt)에서 읽는다. """ + import json, re, sys, time, html, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR / "표준시방서")) SC = Path(str(DATA_DIR)) -KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY +KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY BASE = "https://kcsc.re.kr/OpenApi" UA = {"User-Agent": "Mozilla/5.0"} + def api(path): url = f"{BASE}/{path}{'&' if '?' in path else '?'}key={KEY}" for k in range(3): @@ -42,9 +51,11 @@ def api(path): return None time.sleep(2) + def safe(s): return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".") + # ── HTML → Markdown ── def cell_text(td): t = re.sub(r"", " ", td) @@ -53,6 +64,7 @@ def cell_text(td): t = html.unescape(t) return re.sub(r"\s+", " ", t).strip() + def table_to_md(tbl): cap = "" mcap = re.search(r"]*>(.*?)", tbl, re.S) @@ -78,6 +90,7 @@ def table_to_md(tbl): out.append("| " + " | ".join(esc(c) for c in r) + " |") return "\n".join(out) + def content_to_md(c): if not c: return "" @@ -86,7 +99,7 @@ def content_to_md(c): parts = [] pos = 0 for m in re.finditer(r"", c, re.S): - pre = c[pos:m.start()] + pre = c[pos : m.start()] pt = re.sub(r"<[^>]+>", "", pre) pt = html.unescape(re.sub(r"\s+", " ", pt)).strip() if pt: @@ -105,9 +118,12 @@ def content_to_md(c): t = re.sub(r"<[^>]+>", "", t) return html.unescape(t).strip() + def viewer_to_md(doc): out = [f"# KCS {doc['code']} {doc['name']}", ""] - out.append(f"> 버전 {doc.get('version','')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode','')}") + out.append( + f"> 버전 {doc.get('version', '')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode', '')}" + ) out.append(f"> 출처: https://kcsc.re.kr/OpenApi/CodeViewer/{doc['codeType']}/{doc['code']}") out.append("") last_head = "" @@ -144,6 +160,7 @@ def viewer_to_md(doc): md.append(l) return "\n".join(md).strip() + "\n" + TARGETS = { "콘크리트 표준시방서 (KCS 14 20 00)": ["1420"], "도로공사 표준시방서 (KCS 44 00 00)": ["44"], @@ -151,23 +168,33 @@ TARGETS = { "건설공사 비탈면 표준시방서 (KCS 11 70 00)": ["117", "114030"], } + def run(): codelist = api("CodeList") if not codelist: - print("CodeList 실패"); return - (SC / "kcsc_codelist.json").write_text(json.dumps(codelist, ensure_ascii=False), encoding="utf-8") + print("CodeList 실패") + return + (SC / "kcsc_codelist.json").write_text( + json.dumps(codelist, ensure_ascii=False), encoding="utf-8" + ) kcs = [x for x in codelist if x["codeType"] == "KCS"] print(f"CodeList {len(codelist)}건 (KCS {len(kcs)})") summary = [] for folder_name, prefixes in TARGETS.items(): - codes = sorted({x["code"]: x for x in kcs - if any(x["code"].startswith(p) for p in prefixes)}.items()) + codes = sorted( + {x["code"]: x for x in kcs if any(x["code"].startswith(p) for p in prefixes)}.items() + ) folder = ROOT / safe(folder_name) / "KCS" folder.mkdir(parents=True, exist_ok=True) - idx = [f"# {folder_name} — KCS 코드 목록", "", - f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", "", - "| 코드 | 이름 | 버전 | md |", "|---|---|---|---|"] + idx = [ + f"# {folder_name} — KCS 코드 목록", + "", + f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", + "", + "| 코드 | 이름 | 버전 | md |", + "|---|---|---|---|", + ] ok = 0 for code, meta in codes: doc = api(f"CodeViewer/KCS/{code}") @@ -177,9 +204,9 @@ def run(): d = doc[0] fn = safe(f"{code}_{d['name']}") + ".md" (folder / fn).write_text(viewer_to_md(d), encoding="utf-8") - idx.append(f"| KCS {code} | {d['name']} | {d.get('version','')} | [{fn}](<{fn}>) |") + idx.append(f"| KCS {code} | {d['name']} | {d.get('version', '')} | [{fn}](<{fn}>) |") ok += 1 - print(f" KCS {code} {d['name'][:30]} ({len(d.get('list',[]))}절)", flush=True) + print(f" KCS {code} {d['name'][:30]} ({len(d.get('list', []))}절)", flush=True) time.sleep(0.4) (folder / "_목록.md").write_text("\n".join(idx) + "\n", encoding="utf-8") summary.append((folder_name, len(codes), ok)) @@ -189,5 +216,6 @@ def run(): for n, t, o in summary: print(f" {o}/{t} {n}") + if __name__ == "__main__": run() diff --git a/resources/knowledge/original/_pipeline/get_ks.py b/resources/knowledge/original/_pipeline/get_ks.py index c925dba5..eaac0575 100644 --- a/resources/knowledge/original/_pipeline/get_ks.py +++ b/resources/knowledge/original/_pipeline/get_ks.py @@ -1,29 +1,38 @@ # -*- coding: utf-8 -*- """e나라 표준인증에서 KS 표준 메타데이터·개정이력 수집 (원문은 DRM 열람 전용이라 미수집).""" + import json, re, time, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR / "KS")) SC = Path(str(DATA_DIR)) BASE = "https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do" UA = {"User-Agent": "Mozilla/5.0"} + def fetch(ks): url = f"{BASE}?menuId=919&topMenuId=502&upperMenuId=503&ksNo={urllib.parse.quote(ks)}" for k in range(3): @@ -36,6 +45,7 @@ def fetch(ks): return None time.sleep(2) + def flatten(h): h = re.sub(r"", "", h, flags=re.S) h = re.sub(r"", "", h, flags=re.S) @@ -46,14 +56,16 @@ def flatten(h): t = re.sub(r"(\|\s*)+", "|", t) return t + def field(t, key, stop=("|",)): m = re.search(r"\|" + re.escape(key) + r"\|+([^|]*)", t) return m.group(1).strip() if m else "" + def parse(ks, h): t = flatten(h) i = t.find("|기본정보|") - seg = t[i:i + 4000] if i > 0 else t + seg = t[i : i + 4000] if i > 0 else t d = { "표준번호": field(seg, "표준번호") or ks, "표준명": field(seg, "표준명(한글)"), @@ -71,13 +83,26 @@ def parse(ks, h): hist = [] j = t.find("표준 이력사항") if j > 0: - seg2 = t[j:j + 6000] - for m in re.finditer(r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2): - hist.append({"일자": m.group(1).strip(), "구분": m.group(2).strip(), "고시번호": m.group(3).strip()}) + seg2 = t[j : j + 6000] + for m in re.finditer( + r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2 + ): + hist.append( + { + "일자": m.group(1).strip(), + "구분": m.group(2).strip(), + "고시번호": m.group(3).strip(), + } + ) d["이력"] = hist - d["상태"] = "폐지" if any(x["구분"] == "폐지" for x in hist) else ("현행" if d["표준명"] else "확인필요") + d["상태"] = ( + "폐지" + if any(x["구분"] == "폐지" for x in hist) + else ("현행" if d["표준명"] else "확인필요") + ) return d + CODES = json.load(open(SC / "ks_codes.json", encoding="utf-8")) ROOT.mkdir(parents=True, exist_ok=True) res = [] @@ -90,7 +115,10 @@ for i, ks in enumerate(CODES, 1): d = parse(ks, h) d["조회번호"] = key res.append(d) - print(f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", flush=True) + print( + f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", + flush=True, + ) time.sleep(0.6) json.dump(res, open(SC / "ks_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) diff --git a/resources/knowledge/original/_pipeline/hwp5_text.py b/resources/knowledge/original/_pipeline/hwp5_text.py index 4f79f0c1..acdfa43b 100644 --- a/resources/knowledge/original/_pipeline/hwp5_text.py +++ b/resources/knowledge/original/_pipeline/hwp5_text.py @@ -1,20 +1,28 @@ # -*- coding: utf-8 -*- import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + """HWP5(OLE) 본문 텍스트 추출 — 순수 파이썬. BodyText/Section* 스트림을 (필요시 raw-deflate 해제) 레코드 파싱해 @@ -25,10 +33,11 @@ import olefile HWPTAG_BEGIN = 0x10 HWPTAG_PARA_HEADER = HWPTAG_BEGIN + 50 # 0x42 -HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43 +HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43 HWPTAG_CTRL_HEADER = HWPTAG_BEGIN + 55 # 0x47 HWPTAG_LIST_HEADER = HWPTAG_BEGIN + 56 # 0x48 -HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d +HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d + def is_compressed(ole): with ole.openstream("FileHeader") as f: @@ -37,35 +46,38 @@ def is_compressed(ole): flags = struct.unpack("> 10) & 0x3FF size = (header >> 20) & 0xFFF if size == 0xFFF: - size = struct.unpack("") for c in r) + " |") return ("table", "\n".join(out)) + def walk(container, out): for child in container: if _local(child.tag) != "p": @@ -68,6 +75,7 @@ def walk(container, out): else: out.append(("text", para_text(child))) + def extract(path): z = zipfile.ZipFile(path) secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n)) @@ -76,18 +84,20 @@ def extract(path): walk(ET.fromstring(z.read(sec)), out) return out + # ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ── CHAP = re.compile(r"^제\d+\s*장(\s|$)") -SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.) +SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.) JO = re.compile(r"^제\d+조(의\d+)?\s*\(") MARKERS = [ - ("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")), - ("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")), - ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), - ("circ", re.compile(r"^([①-⑳])\s*(.*)$")), - ("dash", re.compile(r"^([-∙·○])\s+(.*)$")), + ("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")), + ("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")), + ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), + ("circ", re.compile(r"^([①-⑳])\s*(.*)$")), + ("dash", re.compile(r"^([-∙·○])\s+(.*)$")), ] + def marker(s): for k, rx in MARKERS: m = rx.match(s) @@ -95,10 +105,11 @@ def marker(s): return k, m.group(1), m.group(2) return None, "", s + def structure(items, header): """(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용.""" lines = list(header) - stack = [] # 리스트 마커 종류 스택 + stack = [] # 리스트 마커 종류 스택 for kind, val in items: if kind == "table": lines += ["", val, ""] @@ -117,21 +128,26 @@ def structure(items, header): continue # 헤딩류 if CHAP.match(st): - lines += ["", f"## {st}", ""]; stack = []; continue + lines += ["", f"## {st}", ""] + stack = [] + continue if SECN.match(st): - lines += ["", f"### {st}", ""]; stack = []; continue + lines += ["", f"### {st}", ""] + stack = [] + continue if JO.match(st): m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S) lines += ["", f"### {m.group(1)}", ""] if m.group(2).strip(): lines.append(m.group(2).strip()) - stack = []; continue + stack = [] + continue # 리스트 마커 k, mk, rest = marker(st) if k: if k in stack: depth = stack.index(k) - del stack[depth + 1:] + del stack[depth + 1 :] else: stack.append(k) depth = len(stack) - 1 @@ -159,19 +175,23 @@ def structure(items, header): out = out.encode("utf-8", "ignore").decode("utf-8") return out + def to_md(path): items = extract(path) header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""] return structure(items, header) + def hwp5_to_md(path, header=None): """구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존.""" import hwp5_text + items = hwp5_text.extract_items(str(path)) if header is None: header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""] return structure(items, header) + if __name__ == "__main__": for f in sys.argv[1:]: p = Path(f) diff --git a/resources/knowledge/original/_pipeline/index_entry.py b/resources/knowledge/original/_pipeline/index_entry.py index 9bec8430..869dac1a 100644 --- a/resources/knowledge/original/_pipeline/index_entry.py +++ b/resources/knowledge/original/_pipeline/index_entry.py @@ -4,6 +4,7 @@ 법률/행정규칙/표준시방서/<명칭>/ 하위의 본문·별표·첨부·압축해제·KCS md를 전부 링크. 최상위 `0. 참조 법령·기준 목록.md` 의 명칭이 이 _index.md 로 연결된다(gen_md). """ + import re, sys from pathlib import Path @@ -11,9 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent CATS = ["법률", "행정규칙", "표준시방서"] SKIP = {"_index.md", "_목록.md"} + def rel(p, base): return p.relative_to(base).as_posix() + def build(folder): name = folder.name lines = [f"# {name} — 문서 목록", ""] @@ -24,7 +27,9 @@ def build(folder): lines.append("## 본문") lines.append("") for p in top: - label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(p.stem, p.stem.replace("_", " ")) + label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get( + p.stem, p.stem.replace("_", " ") + ) lines.append(f"- [{label}](<{p.name}>)") lines.append("") @@ -70,7 +75,9 @@ def build(folder): if grp: lines.append(f" - **{grp}**") for p in tree[grp]: - lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)") + lines.append( + f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)" + ) else: for p in tree[grp]: lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.name}>)") @@ -86,12 +93,21 @@ def build(folder): lines.append(f"- [{p.stem}]()") lines.append("") - total = len(top) + len(byl) + len(att) + len(kcs) + sum( - len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs) - lines.insert(1, f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total-len(top)-len(byl)-len(att)-len(kcs)} · KCS {len(kcs)})") + total = ( + len(top) + + len(byl) + + len(att) + + len(kcs) + + sum(len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs) + ) + lines.insert( + 1, + f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total - len(top) - len(byl) - len(att) - len(kcs)} · KCS {len(kcs)})", + ) (folder / "_index.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") return total + if __name__ == "__main__": n = 0 for cat in CATS: diff --git a/resources/knowledge/original/_pipeline/index_images.py b/resources/knowledge/original/_pipeline/index_images.py index dc793bac..4ff6c648 100644 --- a/resources/knowledge/original/_pipeline/index_images.py +++ b/resources/knowledge/original/_pipeline/index_images.py @@ -4,6 +4,7 @@ 각 이미지: 미리보기 링크 · 크기 · 형식 · 소속(명칭) · 참조 md 링크. 사용자가 표로 옮길 이미지를 직접 판단한다. """ + import re from pathlib import Path from PIL import Image @@ -11,6 +12,7 @@ from PIL import Image ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "_이미지 목록(표 변환 검토용).md" + def _cand(p): try: w, h = Image.open(p).size @@ -18,6 +20,7 @@ def _cand(p): except Exception: return False + def build(): # 1) 이미지 → 참조 md 역매핑 ref = {} @@ -26,8 +29,11 @@ def build(): continue t = m.read_text(encoding="utf-8") # 경로에 괄호가 있어도 <...> 안이면 확장자까지 잡는다 - for mm in re.finditer(r'!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)' - r'|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)', t): + for mm in re.finditer( + r"!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)" + r"|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)", + t, + ): path = mm.group(1) or mm.group(2) img = (m.parent / path).resolve() ref.setdefault(str(img), []).append(m) @@ -37,7 +43,7 @@ def build(): groups = {} for p in imgs: rel = p.relative_to(ROOT) - parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/… + parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/… cat = parts[0] name = parts[1] if len(parts) > 2 else "(기타)" groups.setdefault((cat, name), []).append(p) @@ -53,31 +59,49 @@ def build(): seen, uniq = set(), [] for m in ref.get(str(p.resolve()), []): if m not in seen: - seen.add(m); uniq.append(m) - rlinks = " · ".join(f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" - for m in uniq[:2]) if uniq else "_미참조_" + seen.add(m) + uniq.append(m) + rlinks = ( + " · ".join( + f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" for m in uniq[:2] + ) + if uniq + else "_미참조_" + ) return f"| ☐ | {idx} | [{p.name[:40]}](<{rel}>) | {size} | {fmt} | {rlinks} |" cand = [p for p in imgs if _cand(p)] rest = [p for p in imgs if not _cand(p)] - L = ["# 이미지 목록 — 표 변환 검토용", "", - f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.", - "> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", "", - f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", "", - "> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", "", - "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", - "|:-:|---:|---|---|---|---|"] + L = [ + "# 이미지 목록 — 표 변환 검토용", + "", + f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.", + "> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", + "", + f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", + "", + "> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", + "", + "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", + "|:-:|---:|---|---|---|---|", + ] for i, p in enumerate(sorted(cand, key=lambda x: -Image.open(x).size[0]), 1): L.append(row(i, p)) - L += ["", f"## 그 외 이미지 ({len(rest)}개)", "", - "> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", "", - "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", - "|:-:|---:|---|---|---|---|"] + L += [ + "", + f"## 그 외 이미지 ({len(rest)}개)", + "", + "> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", + "", + "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", + "|:-:|---:|---|---|---|---|", + ] for i, p in enumerate(sorted(rest), len(cand) + 1): L.append(row(i, p)) OUT.write_text("\n".join(L) + "\n", encoding="utf-8") return len(imgs) + if __name__ == "__main__": n = build() print(f"이미지 목록 {n}개 → {OUT.name}") diff --git a/resources/knowledge/original/_pipeline/index_zip.py b/resources/knowledge/original/_pipeline/index_zip.py index 3ad320c0..e5f676bf 100644 --- a/resources/knowledge/original/_pipeline/index_zip.py +++ b/resources/knowledge/original/_pipeline/index_zip.py @@ -1,14 +1,20 @@ # -*- coding: utf-8 -*- """[압축] 폴더마다 _목록.md 생성 — 내부 HWP→md 파일 트리 색인.""" + import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent + def build(folder): mds = sorted(p for p in folder.rglob("*.md") if p.name != "_목록.md") - lines = [f"# {folder.name} — 압축 해제 문서 목록", "", - f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", ""] + lines = [ + f"# {folder.name} — 압축 해제 문서 목록", + "", + f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", + "", + ] # 하위 폴더 구조 반영 tree = {} for m in mds: @@ -27,6 +33,7 @@ def build(folder): (folder / "_목록.md").write_text("\n".join(lines), encoding="utf-8") return len(mds) + if __name__ == "__main__": n = 0 for folder in ROOT.rglob("[[]압축[]]*"): diff --git a/resources/knowledge/original/_pipeline/pdf2md.py b/resources/knowledge/original/_pipeline/pdf2md.py index 9e8826a4..8e7ea80e 100644 --- a/resources/knowledge/original/_pipeline/pdf2md.py +++ b/resources/knowledge/original/_pipeline/pdf2md.py @@ -5,42 +5,52 @@ - 본문은 법령 번호체계(Ⅰ./1./가./(1)/(가)/1)/가)/①) 기준으로 중첩 리스트화 - PDF 줄바꿈은 꼬리 공백을 신뢰해 그대로 이어붙임 (한글 어절 분리 방지) """ + import re, sys, json from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import pymupdf ROOT = Path(str(ROOT_DIR)) # ── 마커 정의 (우선순위 순, 같은 종류끼리 같은 깊이) ── MARKERS = [ - ("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")), - ("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")), - ("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")), - ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), - ("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")), - ("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")), - ("korb", re.compile(r"^([가-힣]\))\s*(.*)$")), + ("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")), + ("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")), + ("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")), + ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), + ("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")), + ("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")), + ("korb", re.compile(r"^([가-힣]\))\s*(.*)$")), ("circle", re.compile(r"^([①-⑳])\s*(.*)$")), - ("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")), + ("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")), ] HEAD = re.compile(r"^■\s*(.+?)\s*\[(별표|별지)\s*([^\]]*)\]\s*(<[^>]*>)?\s*$") + def match_marker(s): for kind, rx in MARKERS: m = rx.match(s) @@ -48,6 +58,7 @@ def match_marker(s): return kind, m.group(1), m.group(2) return None, "", s + # ── 페이지 → (요소 리스트) ── def _lines(page): out = [] @@ -60,13 +71,17 @@ def _lines(page): out.append((pymupdf.Rect(ln["bbox"]), txt)) return out + def _nk(s): return re.sub(r"[^가-힣0-9A-Za-z%]", "", s) + def safe(s): return re.sub(r'[\\/:*?"<>|\s]+', "_", s).strip("_") -MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등) + +MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등) + def _images(page, pno, doc, picdir, stem): """페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환.""" @@ -84,16 +99,17 @@ def _images(page, pno, doc, picdir, stem): continue idx += 1 picdir.mkdir(parents=True, exist_ok=True) - fn = f"{stem}_p{pno+1}_{idx}.png" + fn = f"{stem}_p{pno + 1}_{idx}.png" try: - if px.n - px.alpha >= 4: # CMYK 등 → RGB + if px.n - px.alpha >= 4: # CMYK 등 → RGB px = pymupdf.Pixmap(pymupdf.csRGB, px) px.save(str(picdir / fn)) except Exception: continue - out.append((r, f"![그림 {pno+1}-{idx}](<../pic/{fn}>)")) + out.append((r, f"![그림 {pno + 1}-{idx}](<../pic/{fn}>)")) return out + def page_elements(page, pno=0, doc=None, picdir=None, stem=""): """세로 순서대로 ('text', y, x, 문자열) / ('table', y, x, md) / ('image', y, x, ref) 반환. @@ -112,15 +128,18 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""): if not md: continue b = pymupdf.Rect(t.bbox) - inside = "".join(_nk(txt) for r, txt in lines - if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2))) + inside = "".join( + _nk(txt) + for r, txt in lines + if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)) + ) if not inside: continue got = _nk(md) hit = sum(1 for ch in set(inside) if ch in got) cov = len(_nk(md)) / len(inside) if inside else 0 if cov < 0.90 or hit < len(set(inside)) * 0.95: - continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지 + continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지 final.append((b, md)) boxes = [b for b, _ in final] @@ -138,6 +157,7 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""): items.sort(key=lambda x: (round(x[1], 1), x[2])) return items + def table_md(t): try: rows = t.extract() @@ -152,8 +172,12 @@ def table_md(t): if len(cnts) == 1 and cnts and max(cnts) > 1: n = max(cnts) for i in range(n): - split.append([(p[i].strip() if len(p) == n else (r[j] if i == 0 else "")) - for j, p in enumerate(parts)]) + split.append( + [ + (p[i].strip() if len(p) == n else (r[j] if i == 0 else "")) + for j, p in enumerate(parts) + ] + ) else: split.append(r) rows = [[re.sub(r"\s+", " ", c).strip() for c in r] for r in split] @@ -163,19 +187,19 @@ def table_md(t): w = max(len(r) for r in rows) rows = [r + [""] * (w - len(r)) for r in rows] esc = lambda c: c.replace("|", "\\|").replace("\n", "
") - out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", - "|" + "|".join(["---"] * w) + "|"] + out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"] for r in rows[1:]: out.append("| " + " | ".join(esc(c) for c in r) + " |") return "\n".join(out) + # ── 변환 본체 ── def convert(pdf_path): doc = pymupdf.open(pdf_path) header, title = "", "" - body = [] # (depth, marker, text) | ("TABLE", md) - stack = [] # 마커 종류 스택 (index = depth) - cur = None # 현재 항목 dict + body = [] # (depth, marker, text) | ("TABLE", md) + stack = [] # 마커 종류 스택 (index = depth) + cur = None # 현재 항목 dict first_lines = [] def flush(): @@ -207,7 +231,7 @@ def convert(pdf_path): # PyMuPDF는 구조적 줄의 들여쓰기를 텍스트 안에 넣고 x0=좌측여백으로 둔다. # x0가 좌측여백보다 큰 줄 = 앞줄에서 넘어온 줄바꿈 조각. wrap = x0 > base_x + 2.0 - raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용) + raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용) s = raw.strip() if not s: continue @@ -225,23 +249,28 @@ def convert(pdf_path): if kind: if kind in stack: depth = stack.index(kind) - del stack[depth + 1:] + del stack[depth + 1 :] else: stack.append(kind) depth = len(stack) - 1 flush() - cur = {"kind": "item", "depth": depth, "marker": mk, - "head": rest.rstrip("\n"), "body": ""} + cur = { + "kind": "item", + "depth": depth, + "marker": mk, + "head": rest.rstrip("\n"), + "body": "", + } else: if cur is None: cur = {"kind": "item", "depth": 0, "marker": "", "head": "", "body": ""} - piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지 - if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐 + piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지 + if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐 if cur["body"]: cur["body"] += piece else: cur["head"] += piece - else: # 들여쓴 줄 = 새 본문 문단 + else: # 들여쓴 줄 = 새 본문 문단 if cur["body"]: cur["body"] += "\n\n" + piece else: @@ -291,10 +320,13 @@ def convert(pdf_path): md.append(l) return "\n".join(md).strip() + "\n" + if __name__ == "__main__": targets = sys.argv[1:] if not targets: - targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [str(p) for p in ROOT.rglob("첨부/*.pdf")] + targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [ + str(p) for p in ROOT.rglob("첨부/*.pdf") + ] ok = fail = 0 for f in targets: p = Path(f) diff --git a/resources/knowledge/original/_pipeline/qc_lint.py b/resources/knowledge/original/_pipeline/qc_lint.py index 955017f0..f034c57d 100644 --- a/resources/knowledge/original/_pipeline/qc_lint.py +++ b/resources/knowledge/original/_pipeline/qc_lint.py @@ -5,6 +5,7 @@ 소스 대조: 별표·첨부 md ↔ 같은 이름 PDF, 현행 본문 md ↔ 같은 이름 XML. 결과를 qc_report.json 으로 저장하고 카테고리별 요약 출력. """ + import json, re from pathlib import Path import pymupdf @@ -12,9 +13,11 @@ import pymupdf ROOT = Path(__file__).resolve().parent.parent OUT = Path(__file__).resolve().parent / "data" + def norm(s): return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s) + def strip_fenced(text): """``` 코드펜스 안을 빈 줄로 치환(위치 보존).""" out, infence = [], False @@ -26,6 +29,7 @@ def strip_fenced(text): out.append("" if infence else l) return chr(10).join(out) + def ncols(line): s = line.strip() if s.startswith("|"): @@ -34,6 +38,7 @@ def ncols(line): s = s[:-1] return len(s.split("|")) + def check_tables(text): """마크다운 표 유효성. (문제 리스트) 반환.""" issues = [] @@ -49,16 +54,19 @@ def check_tables(text): block.append(lines[i]) i += 1 head = ncols(block[0]) - if len(block) < 2 or not set(block[1].replace("|", "").replace(" ", "").replace(":", "")) <= set("-"): - issues.append(f"L{start+1} 구분선 없음/이상") + if len(block) < 2 or not set( + block[1].replace("|", "").replace(" ", "").replace(":", "") + ) <= set("-"): + issues.append(f"L{start + 1} 구분선 없음/이상") continue for j, b in enumerate(block): if j == 1: continue if ncols(b) != head: - issues.append(f"L{start+j+1} 열수 {ncols(b)}≠{head}") + issues.append(f"L{start + j + 1} 열수 {ncols(b)}≠{head}") return issues + def check_space(text): """프로즈(표·펜스 제외)의 한글 12자 이상 연속 비율.""" prose = [l for l in strip_fenced(text).split(chr(10)) if not l.lstrip().startswith("|")] @@ -69,24 +77,33 @@ def check_space(text): runs = re.findall(r"[가-힣]{12,}", t) return round(sum(len(x) for x in runs) / kor, 3) + def check_linebreak(text): """줄바꿈 결함: 표 앞 빈 줄 없음, 헤딩 직후 표 붙음.""" issues = [] lines = strip_fenced(text).split("\n") for i in range(1, len(lines)): s = lines[i].strip() - prev = lines[i-1].strip() + prev = lines[i - 1].strip() # 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님) - if s.startswith("|") and prev and not prev.startswith("|") and not prev.startswith("#") and not prev.startswith(">"): - issues.append(f"L{i+1} 표 앞 빈 줄 없음") + if ( + s.startswith("|") + and prev + and not prev.startswith("|") + and not prev.startswith("#") + and not prev.startswith(">") + ): + issues.append(f"L{i + 1} 표 앞 빈 줄 없음") return issues[:5] + def pdf_stats(pdf): d = pymupdf.open(pdf) txt = "\n".join(p.get_text() for p in d) imgs = sum(len(p.get_images()) for p in d) return txt, imgs + def run(): report = [] targets = [] @@ -120,7 +137,7 @@ def run(): ptxt, pimgs = pdf_stats(pdf) pn, mn = norm(ptxt), norm(text) if pn and len(mn) / len(pn) < 0.98: - rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn)/len(pn):.2f})" + rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn) / len(pn):.2f})" mimg = text.count("〔그림〕") + text.count("![") if pimgs > 0 and mimg == 0: rec["issues"]["사진누락"] = f"PDF 이미지 {pimgs}개 / md 0" @@ -130,7 +147,9 @@ def run(): if rec["issues"]: report.append(rec) - json.dump(report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) + json.dump( + report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1 + ) # 요약 cat = {} @@ -142,8 +161,11 @@ def run(): print("\n=== 심각(내용누락·사진누락·테이블) 상위 ===") sev = [r for r in report if set(r["issues"]) & {"내용누락", "사진누락", "테이블"}] for r in sev[:30]: - ks = ", ".join(f"{k}={v if not isinstance(v,list) else len(v)}" for k, v in r["issues"].items()) + ks = ", ".join( + f"{k}={v if not isinstance(v, list) else len(v)}" for k, v in r["issues"].items() + ) print(f" {r['file'][-64:]} [{ks}]") + if __name__ == "__main__": run() diff --git a/resources/knowledge/original/_pipeline/split_cost_docs.py b/resources/knowledge/original/_pipeline/split_cost_docs.py index 2e4d6c9a..4358d998 100644 --- a/resources/knowledge/original/_pipeline/split_cost_docs.py +++ b/resources/knowledge/original/_pipeline/split_cost_docs.py @@ -13,6 +13,7 @@ - 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약(장 수)이 목차와 일치하는지 확인 - 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(값·표는 정상) — W5 공백 기준 예외로 기록 """ + import re import sys from pathlib import Path @@ -25,8 +26,13 @@ BASE = Path(__file__).resolve().parent.parent / "원가계산" # ────────────────────────── 건협 노임 ────────────────────────── CAK_DIR = "노임단가_건설업_대한건설협회" CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회" -PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표 -CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)] +PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표 +CAK_CH = [ + ("1. 조사개요", 1), + ("2. 임금적용요령", 5), + ("3. 개별직종 노임단가", 9), + ("4. 직종해설", 13), +] CAK_COLS = "| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |" @@ -51,8 +57,10 @@ def parse_cak_table(): if code not in result and len(slots) == 4 and name: result[code] = (name, slots, flag) rows = [CAK_COLS, "|---|---|---|---|---|---|---|"] - rows += [f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |" - for c in sorted(result)] + rows += [ + f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |" + for c in sorted(result) + ] return len(result), "\n".join(rows) + "\n" @@ -72,27 +80,36 @@ def split_cak(): if starts[0][1] is None: starts[0] = (starts[0][0], 0) assert all(s[1] is not None for s in starts), starts - hdr = (f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n" - "> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n") + hdr = ( + f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n" + "> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n" + ) for i, (fname, st) in enumerate(starts): en = starts[i + 1][1] if i + 1 < len(starts) else len(lines) body = "\n".join(lines[st:en]).strip() if "개별직종" in fname: - body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n" - f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n" - "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n" - "> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), " - "`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table) + body = ( + "## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n" + f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n" + "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n" + "> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), " + "`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table + ) (BASE / CAK_DIR / f"{fname}.md").write_text( - f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8") + f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8" + ) print("wrote", fname) # ────────────────────────── 중기중앙회 노임 ────────────────────────── KBIZ_DIR = "노임단가_제조업_중소기업중앙회" KBIZ_STEM = "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회" -KBIZ_CH = [("1. 조사개요", 1), ("2. 조사결과 요약", 12), ("3. 직종별 조사노임", 18), - ("4. 직종코드 및 직종명 해설", 30)] +KBIZ_CH = [ + ("1. 조사개요", 1), + ("2. 조사결과 요약", 12), + ("3. 직종별 조사노임", 18), + ("4. 직종코드 및 직종명 해설", 30), +] def split_kbiz(): @@ -102,13 +119,16 @@ def split_kbiz(): if starts[0][1] is None: starts[0] = (starts[0][0], 0) assert all(s[1] is not None for s in starts), starts - hdr = (f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n" - "> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n") + hdr = ( + f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n" + "> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n" + ) for i, (fname, st) in enumerate(starts): en = starts[i + 1][1] if i + 1 < len(starts) else len(lines) body = "\n".join(lines[st:en]).strip() (BASE / KBIZ_DIR / f"{fname}.md").write_text( - f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8") + f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8" + ) print("wrote", fname) @@ -121,8 +141,13 @@ def split_pumsem(): lines = (BASE / PUM_DIR / f"{PUM_STEM}.md").read_text(encoding="utf-8").splitlines() # 부문 표제 위치 자동 탐지 (짧은 단독 라인) - sec_names = [("01_공통부문", "공통부문"), ("02_토목부문", "토목부문"), ("03_건축부문", "건축부문"), - ("04_기계설비부문", "기계설비부문"), ("05_유지관리부문", "유지관리부문")] + sec_names = [ + ("01_공통부문", "공통부문"), + ("02_토목부문", "토목부문"), + ("03_건축부문", "건축부문"), + ("04_기계설비부문", "기계설비부문"), + ("05_유지관리부문", "유지관리부문"), + ] hits = {} for i, l in enumerate(lines): s = re.sub(r"[\s#>\-·ㆍ]", "", l) @@ -170,11 +195,14 @@ def split_pumsem(): outdir.mkdir(exist_ok=True) for j, (n, nm, st) in enumerate(starts): en = starts[j + 1][2] if j + 1 < len(starts) else b - hdr = (f"# {sec[3:]} 제{n}장 {nm}\n\n" - f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n" - "> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n") + hdr = ( + f"# {sec[3:]} 제{n}장 {nm}\n\n" + f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n" + "> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n" + ) (outdir / f"제{n}장_{nm}.md").write_text( - hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8") + hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8" + ) print(sec, f"{len(starts)}/{len(names)} 장") diff --git a/resources/knowledge/original/_pipeline/verify_pdf2md.py b/resources/knowledge/original/_pipeline/verify_pdf2md.py index b40e300f..c960e4be 100644 --- a/resources/knowledge/original/_pipeline/verify_pdf2md.py +++ b/resources/knowledge/original/_pipeline/verify_pdf2md.py @@ -1,33 +1,44 @@ # -*- coding: utf-8 -*- """PDF → md 변환 손실 검증: 정규화 문자 커버리지 + 줄 단위 누락 확인.""" + import re from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import pymupdf ROOT = Path(str(ROOT_DIR)) + def norm(s): return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s) + bad, miss_lines, empty = [], [], [] tot = 0 -for pdf in sorted(list(ROOT.rglob("별표/*.pdf"))+list(ROOT.rglob("첨부/*.pdf"))): +for pdf in sorted(list(ROOT.rglob("별표/*.pdf")) + list(ROOT.rglob("첨부/*.pdf"))): md = pdf.with_suffix(".md") if not md.exists(): bad.append((pdf.name, "md 없음", 0, 0)) diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py b/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py index d4c2bafb..5d3a9f65 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py @@ -47,7 +47,14 @@ def sheet_map(z): for name, rid in RE_SHEET.findall(wb): tgt = rels.get(rid) if tgt: - out.append((name, "xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/") if not tgt.startswith("xl/") else tgt)) + out.append( + ( + name, + "xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/") + if not tgt.startswith("xl/") + else tgt, + ) + ) return out diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py b/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py index d2637865..e06f67aa 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py @@ -13,7 +13,9 @@ ROOT = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") KNOW = os.path.normpath(os.path.join(ROOT, "knowledge")) # 이 스크립트는 resources/knowledge/original/원가계산/STmate/_scripts/ 에 위치 # → knowledge 루트 = ../../../.. -KNOW = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..")) +KNOW = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..") +) sys.stdout.reconfigure(encoding="utf-8") diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py b/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py index b2272930..4ec48612 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py @@ -42,9 +42,7 @@ def records(path, table): def mode_key(recs, width): """열별 최빈 바이트 = 키스트림 후보 (공백 암호문 가정)""" - return bytes( - Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width) - ) + return bytes(Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width)) def probe(table, clip=None): @@ -62,9 +60,7 @@ def probe(table, clip=None): for name, key, nrec in keys: n = min(len(base_key), len(key)) same = sum(1 for a, b in zip(base_key[:n], key[:n]) if a == b) - print( - f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)" - ) + print(f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)") print() diff --git a/scratch/test_vworld_download.py b/scratch/test_vworld_download.py index 83d4cb43..dc3100c5 100644 --- a/scratch/test_vworld_download.py +++ b/scratch/test_vworld_download.py @@ -10,22 +10,31 @@ sys.path.insert(0, str(PROJECT_ROOT)) from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map from B04_PreProcess.B04_PreProcess_Engine_GisVector import download_all_gis_vectors -PRJ_PATH = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" -NPZ_PATH = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed/ground_points_csf.npz" -OUTPUT_DIR = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed" +PRJ_PATH = ( + PROJECT_ROOT + / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" +) +NPZ_PATH = ( + PROJECT_ROOT + / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed/ground_points_csf.npz" +) +OUTPUT_DIR = ( + PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed" +) + def main(): print("Loading NPZ to get bounds...") with np.load(NPZ_PATH) as data: bounds = data["bounds"] print("Raw bounds:", bounds) - + bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - + print("\n--- Test 1: VWorld Satellite Map Download ---") try: res = download_vworld_satellite_map( @@ -33,7 +42,7 @@ def main(): bounds=bounds_dict, output_dir=OUTPUT_DIR, layer_name="Satellite", - ext="jpeg" + ext="jpeg", ) print("Download Result:", res) except Exception as e: @@ -42,15 +51,12 @@ def main(): print("\n--- Test 2: GIS Vector Download ---") try: - download_all_gis_vectors( - prj_path=PRJ_PATH, - bounds_meter=bounds_dict, - output_dir=OUTPUT_DIR - ) + download_all_gis_vectors(prj_path=PRJ_PATH, bounds_meter=bounds_dict, output_dir=OUTPUT_DIR) print("GIS Vector Download completed.") except Exception as e: print("GIS Download Failed:") traceback.print_exc() + if __name__ == "__main__": main() diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 95f44e1b..97b8a443 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -29,31 +29,16 @@ export const ui_locales_b1 = { B01_Account_Field_Name: ["이름", "Name"], B01_Account_Field_Email: ["이메일", "Email"], B01_Account_Field_Phone: ["연락처", "Phone"], - B01_Account_Field_Phone_Placeholder: [ - "연락처를 입력하세요", - "Enter phone number", - ], + B01_Account_Field_Phone_Placeholder: ["연락처를 입력하세요", "Enter phone number"], B01_Account_Field_CurrentPw: ["현재 비밀번호", "Current password"], B01_Account_Field_NewPw: ["새 비밀번호", "New password"], B01_Account_Field_ConfirmPw: ["새 비밀번호 확인", "Confirm new password"], B01_Account_Save_Profile: ["기본 정보 저장", "Save profile"], B01_Account_Save_Password: ["비밀번호 변경", "Change password"], - B01_Account_Success_Profile: [ - "기본 정보가 저장되었습니다.", - "Profile has been saved.", - ], - B01_Account_Success_Password: [ - "비밀번호가 변경되었습니다.", - "Password has been changed.", - ], - B01_Account_Error_Required: [ - "필수 항목을 입력하세요.", - "Please fill in required fields.", - ], - B01_Account_Error_PwMismatch: [ - "새 비밀번호가 일치하지 않습니다.", - "New passwords do not match.", - ], + B01_Account_Success_Profile: ["기본 정보가 저장되었습니다.", "Profile has been saved."], + B01_Account_Success_Password: ["비밀번호가 변경되었습니다.", "Password has been changed."], + B01_Account_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], + B01_Account_Error_PwMismatch: ["새 비밀번호가 일치하지 않습니다.", "New passwords do not match."], B01_Account_Error_PwLength: [ "비밀번호는 8자 이상이어야 합니다.", "Password must be at least 8 characters.", @@ -94,14 +79,8 @@ export const ui_locales_b1 = { /* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */ B01_Temp_Section: ["임시 보관함", "Temporary storage"], B01_Temp_Field_Name: ["보관 이름", "Storage name"], - B01_Temp_Field_Name_Placeholder: [ - "예: 2026년 3공구 측량자료", - "e.g. 2026 Section 3 survey", - ], - B01_Temp_Field_Files: [ - "파일 선택 (계획노선·라이다·좌표계·래스터)", - "Select files", - ], + B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"], + B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"], B01_Temp_Btn_Pick: ["파일 선택", "Choose files"], B01_Temp_Btn_Add: ["파일 추가", "Add files"], B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"], @@ -121,10 +100,7 @@ export const ui_locales_b1 = { "Delete this file from temporary storage?", ], B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."], - B01_Temp_File_Delete_Failed: [ - "파일 삭제에 실패했습니다.", - "Failed to delete the file.", - ], + B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."], /* 보관 기간은 섹션 제목 옆 태그로만 알린다(안내 문단 폐기, 2026-08-08). */ B01_Temp_Hint_Days: ["일 보관", " days retained"], B01_Temp_Status_Uploading: ["업로드 중", "Uploading"], @@ -135,18 +111,9 @@ export const ui_locales_b1 = { B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"], B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."], B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."], - B01_Temp_Upload_Success: [ - "보관함에 저장했습니다.", - "Saved to temporary storage.", - ], - B01_Temp_Upload_Failed: [ - "보관함 업로드에 실패했습니다.", - "Failed to upload.", - ], - B01_Temp_Load_Failed: [ - "보관함을 불러오지 못했습니다.", - "Failed to load storage.", - ], + B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."], + B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."], + B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."], B01_Temp_Delete_Confirm: [ "이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.", "Delete this stored set? This cannot be undone.", @@ -185,10 +152,7 @@ export const ui_locales_b1 = { B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"], B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"], B01_Dashboard_Saved: ["저장되었습니다.", "Saved."], - B01_Dashboard_LoadFailed: [ - "대시보드를 불러오지 못했습니다.", - "Failed to load dashboard.", - ], + B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."], B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."], // 프로젝트 관리 @@ -200,10 +164,7 @@ export const ui_locales_b1 = { B01_Dashboard_EditUser: ["사용자 수정", "Edit User"], B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"], B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], - B01_Dashboard_SelectAvailableUsers: [ - "사용 가능한 사용자 선택", - "Select Available Users", - ], + B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], // 확인 메시지 B01_Dashboard_Confirm_DeleteProject: [ @@ -215,10 +176,7 @@ export const ui_locales_b1 = { "[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?", "[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?", ], - B01_Dashboard_Confirm_DeleteUser: [ - "사용자를 삭제하시겠습니까?", - "Delete this user?", - ], + B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], B01_Dashboard_Confirm_LastAdmin: [ "회사의 유일한 관리자는 삭제할 수 없습니다.", "Cannot delete the last admin of the company.", @@ -249,24 +207,15 @@ export const ui_locales_b1 = { B02_Proj_RoadType_Work: ["작업임도", "Work forest road"], B02_Proj_Field_Year: ["사업 연도", "Project year"], B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"], - B02_Proj_Field_Length_Placeholder: [ - "예상 노선 길이", - "Estimated route length", - ], + B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"], B02_Proj_Field_Memo: ["비고", "Notes"], - B02_Proj_Field_Memo_Placeholder: [ - "추가 메모 (선택)", - "Additional notes (optional)", - ], + B02_Proj_Field_Memo_Placeholder: ["추가 메모 (선택)", "Additional notes (optional)"], B02_Proj_Submit: ["프로젝트 생성", "Create project"], B02_Proj_Success: [ "프로젝트가 생성되었습니다. 파일 입력 단계로 이동합니다.", "Project created. Moving to the file input step.", ], - B02_Proj_Error_Required: [ - "필수 항목을 입력하세요.", - "Please fill in required fields.", - ], + B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일입력", "File Input"], @@ -287,10 +236,7 @@ export const ui_locales_b1 = { "현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.", "No current project is selected. Create or select a project first.", ], - B03_File_Error_Required: [ - "업로드할 파일을 선택하세요.", - "Select files to upload.", - ], + B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."], B03_File_Error_Count: [ "한 번에 업로드할 수 있는 파일 수를 초과했습니다.", "Too many files were selected for one upload.", @@ -303,22 +249,10 @@ export const ui_locales_b1 = { "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 넣을 수 없습니다.", "LAS/LAZ files cannot be added while designing without LAS.", ], - B03_File_Error_Extension: [ - "허용되지 않은 파일 형식입니다.", - "Unsupported file type.", - ], - B03_File_Error_Size: [ - "파일 크기 제한을 초과했습니다.", - "File size limit exceeded.", - ], - B03_File_Upload_Success: [ - "입력 파일 업로드를 완료했습니다.", - "Input files uploaded.", - ], - B03_File_Upload_Failed: [ - "파일 업로드에 실패했습니다.", - "File upload failed.", - ], + B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."], + B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."], + B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."], + B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."], B03_File_Analysis_InProgress: [ "WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.", "WF1 analysis is running in the background. You will move automatically when it completes.", @@ -361,10 +295,7 @@ export const ui_locales_b1 = { B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ - B03_Temp_Btn_Open: [ - "임시 보관함에서 불러오기", - "Load from temporary storage", - ], + B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], B03_Temp_Selected: ["선택됨:", "Selected:"], B03_Temp_FileCount: ["개 파일", " files"], @@ -374,18 +305,12 @@ export const ui_locales_b1 = { "No usable stored set. Upload all required files in the dashboard temporary storage first.", ], B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."], - B03_Temp_Load_Failed: [ - "보관 자료를 불러오지 못했습니다.", - "Failed to load stored sets.", - ], + B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."], B03_Temp_Attach_Success: [ "보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.", "Stored files moved to the project. Analysis started.", ], - B03_Temp_Attach_Failed: [ - "보관 자료 연결에 실패했습니다.", - "Failed to attach stored files.", - ], + B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."], B03_Temp_Attach_NoAnalysis: [ "파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.", "Files moved, but analysis did not start (no point cloud file).", @@ -400,8 +325,8 @@ export const ui_locales_b1 = { ], B03_File_Error_RequiredSlots: [ "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", - "Fill every required card: the planned route (a CSV or a full shapefile set), " - + "LAS/LAZ, and the terrain PRJ and TFW.", + "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.", @@ -415,10 +340,7 @@ export const ui_locales_b1 = { B03_File_Status_Completed: ["완료", "Completed"], B03_File_Status_Failed: ["실패", "Failed"], B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"], - B03_File_Restore_State: [ - "저장된 업로드/분석 상태 복구", - "Restored upload/analysis state", - ], + B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"], B03_File_Resume_Button: ["업로드 재개", "Resume upload"], B03_File_New_Button: ["새 파일로 시작", "Start new file"], B03_File_Overview_Complete: [ @@ -455,10 +377,7 @@ export const ui_locales_b1 = { B04_Surface_Group_Filters: ["지면 필터", "Ground filter"], B04_Surface_Group_Methods: ["서피스", "Surface"], B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"], - B04_Surface_SheetSurface: [ - "도엽등고 3D 서피스", - "Map-sheet contour 3D surface", - ], + B04_Surface_SheetSurface: ["도엽등고 3D 서피스", "Map-sheet contour 3D surface"], B04_Surface_SheetLidar: ["라이다 겹쳐 보기", "Overlay LiDAR"], B04_Surface_SheetLidar_Missing: [ "겹쳐 볼 라이다 지표면 모델이 없습니다.", @@ -485,16 +404,10 @@ export const ui_locales_b1 = { B04_Surface_Input_FileName: ["파일명", "File name"], B04_Surface_Input_Crs: ["좌표계", "CRS"], B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"], - B04_Surface_PointCloud_Title: [ - "포인트클라우드 미리보기", - "Point cloud preview", - ], + B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"], B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"], B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"], - B04_Surface_GroundStats_Empty: [ - "표시할 지면 통계가 없습니다.", - "No ground stats to display.", - ], + B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."], B04_Surface_GroundStats_Filter: ["필터", "Filter"], B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"], B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"], @@ -512,10 +425,7 @@ export const ui_locales_b1 = { "모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}", "Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}", ], - B04_Surface_Confirm_Failed: [ - "모델 확정에 실패했습니다.", - "Failed to confirm model.", - ], + B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."], B04_Surface_Build_Confirm: [ "이 조합({filter} · {method})은 아직 만들어지지 않았습니다.\n지금 계산해 영구 저장할까요? 자료량에 따라 수 분이 걸립니다.", "This combination ({filter} · {method}) has not been built yet.\nBuild and store it now? This can take several minutes depending on data size.", @@ -533,10 +443,7 @@ export const ui_locales_b1 = { "지표면 모델 계산에 실패했습니다.", "Failed to build the surface model.", ], - B04_Surface_Map_Title: [ - "2D 배경 지도 및 GIS 레이어", - "2D Basemap and GIS Layers", - ], + B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"], B04_Surface_Map_Background: ["배경 지도", "Basemap"], B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], B04_Surface_Map_None: ["없음", "None"], @@ -567,20 +474,14 @@ export const ui_locales_b1 = { "Failed to load the drainage analysis.", ], /* {message}=원인 */ - B04_Surface_Watershed_Failed: [ - "유역 분석 실패: {message}", - "Basin analysis failed: {message}", - ], + B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"], B04_Surface_Watershed_NoSaved: [ "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", "No stored drainage analysis. Press [Basin analysis].", ], B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"], /* {seconds}=재산정에 걸린 시간(초) */ - B04_Surface_Watershed_Origin_Recomputed: [ - "재산정 {seconds}초", - "Recomputed in {seconds}s", - ], + B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"], /* 도로 유입 흐름 강도 */ B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"], B04_Surface_Flow_Strength_Tip: [ @@ -593,10 +494,7 @@ export const ui_locales_b1 = { "노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.", "Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.", ], - B04_Surface_Flow_Inflow_Loading: [ - "유입 셀을 불러오는 중…", - "Loading the contributing cells…", - ], + B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"], /* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */ B04_Surface_Flow_Inflow_Summary: [ "유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m", @@ -668,37 +566,16 @@ export const ui_locales_b1 = { "배경 지도 또는 GIS 레이어를 선택하세요.", "Select a basemap or GIS layer.", ], - B04_Surface_Map_Loading: [ - "지도 레이어를 불러오는 중입니다.", - "Loading map layers.", - ], + B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."], B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"], - B04_Surface_Map_LoadFailed: [ - "지도 레이어를 불러오지 못했습니다.", - "Failed to load map.", - ], - B04_Surface_Error_Project: [ - "먼저 프로젝트를 선택하세요.", - "Select a project first.", - ], - B04_Surface_Error_InputId: [ - "유효한 입력 파일 ID를 입력하세요.", - "Enter a valid input file ID.", - ], + B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."], + B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], + B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."], B04_Surface_Error_Selection: [ "지면 필터와 지표면 표현을 각각 1개 이상 선택하세요.", "Select at least one filter and one method.", ], - B04_Surface_Analyze_Success: [ - "지표면 분석을 완료했습니다.", - "Surface analysis complete.", - ], - B04_Surface_Analyze_Failed: [ - "지표면 분석에 실패했습니다.", - "Surface analysis failed.", - ], - B04_Surface_Load_Failed: [ - "모델 목록을 불러오지 못했습니다.", - "Failed to load models.", - ], + B04_Surface_Analyze_Success: ["지표면 분석을 완료했습니다.", "Surface analysis complete."], + B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."], + B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."], } as const satisfies Record;