style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량 재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만. - 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100) - `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100). `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외 - 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경) 두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물 폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가 `core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가 실제 포맷 차이를 가리고 있었음. 검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped / 0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음). 포맷터 재실행 시 prettier·biome 모두 변경 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면
|
||||
# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫.
|
||||
B07_DesignDetail/openwebcad/
|
||||
|
||||
# 빌드·산출물·가상환경 — 포맷 대상이 아니다.
|
||||
dist/
|
||||
venv/
|
||||
storage/
|
||||
tmp/
|
||||
graphify-out/
|
||||
@@ -69,15 +69,12 @@ export async function uploadProjectFiles(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/files`,
|
||||
{
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -92,9 +89,7 @@ export async function createUploadSession(
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
|
||||
{
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -106,8 +101,7 @@ export async function createUploadSession(
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
}
|
||||
|
||||
@@ -138,9 +132,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/finalize`,
|
||||
{
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -151,8 +143,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
}
|
||||
|
||||
@@ -160,13 +151,10 @@ export async function fetchUploadStatus(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<UploadStatusResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
|
||||
{
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<UploadStatusResponse>(response);
|
||||
}
|
||||
|
||||
@@ -200,16 +188,11 @@ export interface UploadOverviewResponse {
|
||||
}
|
||||
|
||||
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
||||
export async function fetchUploadOverview(
|
||||
projectId: string,
|
||||
): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
|
||||
{
|
||||
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<UploadOverviewResponse>(response);
|
||||
}
|
||||
|
||||
@@ -223,15 +206,10 @@ export interface WF1AnalysisStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkWF1AnalysisStatus(
|
||||
projectId: string,
|
||||
): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/status`,
|
||||
{
|
||||
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
});
|
||||
return await readJsonOrThrow<WF1AnalysisStatus>(response);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import {
|
||||
createStepBar,
|
||||
WORKFLOW_STEP_ICONS,
|
||||
} from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchUploadOverview,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
@@ -129,10 +123,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return Array.from(slots.values()).filter((state) => state.file);
|
||||
}
|
||||
|
||||
function setCardState(
|
||||
slot: FileSlot,
|
||||
stateName: "empty" | "selected" | UploadStatus,
|
||||
): void {
|
||||
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
|
||||
const card = cardMap.get(slot);
|
||||
if (!card) return;
|
||||
card.classList.remove(
|
||||
@@ -146,9 +137,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const cssState = stateName === "failed" ? "error" : stateName;
|
||||
card.classList.add(`b03-file__card--${cssState}`);
|
||||
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__card-badge-container",
|
||||
);
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
|
||||
if (badgeContainer) {
|
||||
badgeContainer.replaceChildren();
|
||||
if (stateName === "empty") {
|
||||
@@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!state || !card) return;
|
||||
renderExtensionLabel(card, state);
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
);
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-size",
|
||||
);
|
||||
const progress = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__progress-bar",
|
||||
);
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-bytes",
|
||||
);
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-speed",
|
||||
);
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-eta",
|
||||
);
|
||||
const error = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__error-message",
|
||||
);
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
);
|
||||
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
|
||||
|
||||
const percent = state.file
|
||||
? Math.min(100, (state.progressBytes / state.file.size) * 100)
|
||||
: 0;
|
||||
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
if (fileName)
|
||||
fileName.textContent =
|
||||
state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -271,13 +240,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file)
|
||||
setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else
|
||||
setCardState(
|
||||
slot,
|
||||
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
|
||||
);
|
||||
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
@@ -289,23 +253,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(
|
||||
file: File,
|
||||
state: FileSlotState,
|
||||
): string | null {
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
if (!state.extensions.includes(extension))
|
||||
return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes)
|
||||
return L("B03_File_Error_Size");
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignFileToSlot(
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
@@ -317,19 +273,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(
|
||||
state.slot,
|
||||
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
|
||||
);
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
|
||||
if (state.serverUploaded) {
|
||||
const accepted = await confirmReplaceUpload(
|
||||
L(state.labelKey),
|
||||
state.serverUploaded.name,
|
||||
);
|
||||
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
|
||||
if (!accepted) return;
|
||||
}
|
||||
|
||||
@@ -350,18 +300,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function onFileSelected(
|
||||
selection: readonly File[],
|
||||
targetSlot?: FileSlot,
|
||||
): void {
|
||||
function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void {
|
||||
if (selection.length === 0) return;
|
||||
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
|
||||
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
|
||||
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
|
||||
const files = lasFreeDesign
|
||||
? selection.filter(
|
||||
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
|
||||
)
|
||||
? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name)))
|
||||
: selection;
|
||||
const blocked = files.length !== selection.length;
|
||||
if (blocked && files.length === 0) {
|
||||
@@ -433,8 +378,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
|
||||
return routeIsShapefile();
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
|
||||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||||
return state.isRequired;
|
||||
}
|
||||
@@ -447,14 +391,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) =>
|
||||
isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded)
|
||||
return L("B03_File_Error_Las");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
}
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
@@ -490,8 +432,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
: "prj"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
@@ -533,26 +474,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!card) throw new Error("file-card-template is invalid");
|
||||
card.dataset.slotId = state.slot;
|
||||
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(
|
||||
state.labelKey,
|
||||
);
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
|
||||
renderExtensionLabel(card, state);
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-select",
|
||||
)!;
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
select.textContent = L("B03_File_Card_Select");
|
||||
select.addEventListener("click", () => input.click());
|
||||
input.addEventListener("change", () => {
|
||||
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
|
||||
input.value = "";
|
||||
});
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
)!;
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
|
||||
remove.textContent = "×";
|
||||
remove.title = L("B03_File_Card_Remove");
|
||||
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
|
||||
@@ -599,9 +532,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(
|
||||
makeSessionKey(activeProjectId, state.file!),
|
||||
);
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
@@ -671,8 +602,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
@@ -701,9 +631,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startChunkedUpload(
|
||||
targetStates = selectedStates(),
|
||||
): Promise<void> {
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
if (isUploading) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
@@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find(
|
||||
(state) => state.uploadStatus === "uploading",
|
||||
);
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
@@ -789,9 +714,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
onFileSelected(
|
||||
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
|
||||
);
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -803,9 +726,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () =>
|
||||
dropzone.classList.remove("is-dragging"),
|
||||
);
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
uploadButton = createButton({
|
||||
@@ -872,10 +793,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
lasFreeCheck.addEventListener("change", () => {
|
||||
lasFreeDesign = lasFreeCheck.checked;
|
||||
if (activeProjectId) {
|
||||
localStorage.setItem(
|
||||
`b03_las_free_${activeProjectId}`,
|
||||
lasFreeDesign ? "1" : "0",
|
||||
);
|
||||
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
|
||||
}
|
||||
applyLasFreeState();
|
||||
pageError.textContent = "";
|
||||
@@ -885,13 +803,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
terrainGroup.append(lasFreeRow);
|
||||
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.append(routeGroup);
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.append(terrainGroup);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
|
||||
@@ -295,9 +295,7 @@
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(
|
||||
--spacing-8
|
||||
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
@@ -348,9 +346,7 @@
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(
|
||||
--spacing-8
|
||||
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,40 +6,16 @@ import { ui_locales } from "@ui/ui_template_locale";
|
||||
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
|
||||
*/
|
||||
export type FileSlot =
|
||||
| "csv"
|
||||
| "shx"
|
||||
| "dbf"
|
||||
| "cpg"
|
||||
| "route_prj"
|
||||
| "las_laz"
|
||||
| "prj"
|
||||
| "tfw"
|
||||
| "tif"
|
||||
| "dxf";
|
||||
"csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
|
||||
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = [
|
||||
"csv",
|
||||
"shx",
|
||||
"dbf",
|
||||
"cpg",
|
||||
"route_prj",
|
||||
];
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = ["csv", "shx", "dbf", "cpg", "route_prj"];
|
||||
|
||||
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = [
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
];
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = ["las_laz", "prj", "tfw", "tif"];
|
||||
|
||||
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
|
||||
"shx",
|
||||
"dbf",
|
||||
"route_prj",
|
||||
];
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = ["shx", "dbf", "route_prj"];
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -179,9 +155,7 @@ export function planSlotAssignments(
|
||||
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
|
||||
}
|
||||
const config = slotConfigs.find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
);
|
||||
return { file, slot: config?.slot };
|
||||
});
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
@@ -24,10 +18,7 @@ import {
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
|
||||
import {
|
||||
makeSessionKey,
|
||||
type FileSlotState,
|
||||
@@ -42,10 +33,7 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
export function confirmReplaceUpload(
|
||||
slotLabel: string,
|
||||
fileName: string,
|
||||
): Promise<boolean> {
|
||||
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
@@ -120,9 +108,7 @@ export async function uploadOneFile(
|
||||
|
||||
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
|
||||
const fingerprint = state.uploadSessionId
|
||||
? null
|
||||
: await fileFingerprint(file);
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
@@ -156,22 +142,11 @@ export async function uploadOneFile(
|
||||
const start = chunkIndex * chunkSizeBytes;
|
||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||
const chunkStartedAt = performance.now();
|
||||
await uploadFileChunk(
|
||||
projectId,
|
||||
session,
|
||||
chunkIndex,
|
||||
file.slice(start, end),
|
||||
);
|
||||
const elapsedSec = Math.max(
|
||||
0.001,
|
||||
(performance.now() - chunkStartedAt) / 1000,
|
||||
);
|
||||
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
|
||||
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
|
||||
state.progressBytes = end;
|
||||
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
|
||||
state.etaSeconds =
|
||||
state.speedMbs > 0
|
||||
? (file.size - end) / 1024 / 1024 / state.speedMbs
|
||||
: null;
|
||||
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
|
||||
|
||||
const stored: StoredUploadSession = {
|
||||
key: storageKey,
|
||||
@@ -188,10 +163,7 @@ export async function uploadOneFile(
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const now = performance.now();
|
||||
if (
|
||||
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
|
||||
chunkIndex === totalChunks - 1
|
||||
) {
|
||||
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
|
||||
lastPaintAt = now;
|
||||
onProgress();
|
||||
}
|
||||
@@ -213,10 +185,7 @@ export async function uploadOneFile(
|
||||
});
|
||||
state.progressBytes = file.size;
|
||||
state.speedMbs =
|
||||
file.size /
|
||||
1024 /
|
||||
1024 /
|
||||
Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
state.etaSeconds = 0;
|
||||
state.uploadStatus = "completed";
|
||||
onProgress();
|
||||
@@ -237,12 +206,9 @@ export async function uploadOneFile(
|
||||
*
|
||||
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
|
||||
*/
|
||||
export function isInitialPipelineRunning(
|
||||
state: WorkflowState | undefined,
|
||||
): boolean {
|
||||
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
|
||||
if (!state?.stages?.length) return false;
|
||||
const stageAt = (stageNo: number) =>
|
||||
state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const fileInput = stageAt(0);
|
||||
const preprocess = stageAt(1);
|
||||
const section = stageAt(3);
|
||||
|
||||
@@ -8,10 +8,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
||||
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
||||
import type {
|
||||
SurfaceBounds,
|
||||
SurfaceModelSummary,
|
||||
} from "./B04_PreProcess_Api_Fetch";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
@@ -43,11 +40,7 @@ export interface SurfaceTerrainViewer {
|
||||
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
|
||||
setSelection: (sourceFilter: string, method: string) => void;
|
||||
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
|
||||
showOverlay: (
|
||||
sourceFilter: string,
|
||||
method: string,
|
||||
smooth: boolean,
|
||||
) => Promise<boolean>;
|
||||
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
||||
@@ -242,12 +235,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
scene.background = new THREE.Color(color);
|
||||
});
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
SURFACE_CAMERA_FOV,
|
||||
1,
|
||||
0.01,
|
||||
100000,
|
||||
);
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
|
||||
@@ -291,8 +279,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
function disposeObject(obj: THREE.Object3D) {
|
||||
obj.traverse((child) => {
|
||||
const renderable = child as
|
||||
THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
renderable.geometry?.dispose();
|
||||
const material = renderable.material;
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||
@@ -414,9 +401,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(routeLineColor()),
|
||||
});
|
||||
routeGroup.add(
|
||||
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
|
||||
);
|
||||
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
|
||||
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
|
||||
if (terrainMesh) fitCamera(terrainMesh);
|
||||
}
|
||||
@@ -459,8 +444,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
const fitCamera = (object: THREE.Object3D) => {
|
||||
const { span } = getFitParams(object);
|
||||
const aspect =
|
||||
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const bounds = fitBounds();
|
||||
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
|
||||
controls.target.set(0, 0, 0);
|
||||
@@ -524,15 +508,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
||||
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
||||
const match = currentModelsList.find((m) => {
|
||||
const typeMatches =
|
||||
m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const configuredFilter = m.generation_params?.source_filter;
|
||||
const filterMatches =
|
||||
(typeof configuredFilter === "string" &&
|
||||
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
|
||||
Boolean(
|
||||
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
|
||||
);
|
||||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
|
||||
return typeMatches && filterMatches;
|
||||
});
|
||||
|
||||
@@ -543,8 +524,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
|
||||
const modelId = match.id;
|
||||
const isSmooth =
|
||||
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
currentModelId = modelId;
|
||||
currentModelSmooth = isSmooth;
|
||||
const generation = ++loadGeneration;
|
||||
@@ -591,8 +571,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
child.material.vertexColors =
|
||||
child.geometry.hasAttribute("color");
|
||||
child.material.vertexColors = child.geometry.hasAttribute("color");
|
||||
}
|
||||
});
|
||||
gltf.scene.visible = surfCheck.checked;
|
||||
@@ -605,8 +584,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
},
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent =
|
||||
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
showProgress(null, null);
|
||||
},
|
||||
);
|
||||
@@ -817,26 +795,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.hidden = false;
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(
|
||||
dist,
|
||||
viewerArea.clientHeight,
|
||||
);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
const prettyMeters = niceScaleDistance(roughMeters);
|
||||
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
||||
scaleLabel.textContent =
|
||||
prettyMeters >= 1000
|
||||
? `${(prettyMeters / 1000).toFixed(0)} km`
|
||||
: `${prettyMeters} m`;
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.hidden = true;
|
||||
}
|
||||
|
||||
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
||||
if (
|
||||
labelsDirty ||
|
||||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
|
||||
) {
|
||||
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
|
||||
labelsDirty = false;
|
||||
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
|
||||
labelElements.forEach((label) => {
|
||||
@@ -878,8 +848,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
intervalForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const interval = Number(intervalInput.value);
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
|
||||
return;
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
|
||||
intervalSubmit.disabled = true;
|
||||
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
||||
intervalSubmit.disabled = false;
|
||||
@@ -928,13 +897,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
clearOverlay();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return loadOverlay(
|
||||
currentProjectId,
|
||||
currentModelsList,
|
||||
sourceFilter,
|
||||
method,
|
||||
smooth,
|
||||
);
|
||||
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
@@ -951,8 +914,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
setContourInterval(interval) {
|
||||
if (Number.isFinite(interval) && interval > 0)
|
||||
intervalInput.value = String(interval);
|
||||
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
|
||||
},
|
||||
getContourInterval() {
|
||||
return Number.parseFloat(intervalInput.value);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
export interface DesignDrawingItem {
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
@@ -68,10 +67,7 @@ export interface CrossDesignInfo {
|
||||
cross_slope_pct?: number;
|
||||
paved?: boolean;
|
||||
ditch: DitchSpec;
|
||||
road_edges?: Record<
|
||||
"left" | "right",
|
||||
{ offset_m: number; elevation_m: number }
|
||||
>;
|
||||
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
@@ -84,8 +80,7 @@ export interface DesignDrawingResponse {
|
||||
route_id: number;
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
@@ -102,10 +97,7 @@ export interface DesignDrawingConfirmResponse {
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -116,17 +108,14 @@ async function requestJson<T>(
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok)
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchDesignDrawingList(
|
||||
projectId: string,
|
||||
): Promise<DesignDrawingListResponse> {
|
||||
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings`);
|
||||
}
|
||||
|
||||
@@ -134,9 +123,7 @@ export function fetchDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<DesignDrawingResponse> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
|
||||
);
|
||||
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
|
||||
}
|
||||
|
||||
export function confirmDesignDrawing(
|
||||
@@ -154,10 +141,7 @@ export function confirmDesignDrawing(
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidateDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<void> {
|
||||
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
||||
{ method: "POST" },
|
||||
@@ -173,16 +157,11 @@ export interface FrameTemplateResponse {
|
||||
customized: boolean;
|
||||
}
|
||||
|
||||
export function fetchFrameTemplate(
|
||||
projectId: string,
|
||||
): Promise<FrameTemplateResponse> {
|
||||
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`);
|
||||
}
|
||||
|
||||
export function saveFrameTemplate(
|
||||
projectId: string,
|
||||
drawing: CadDrawing,
|
||||
): Promise<void> {
|
||||
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ drawing }),
|
||||
|
||||
@@ -37,9 +37,7 @@ interface Options {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function createFrameTemplateEditor(
|
||||
options: Options,
|
||||
): FrameTemplateEditor {
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
|
||||
const banner = document.createElement("div");
|
||||
@@ -94,10 +92,7 @@ export function createFrameTemplateEditor(
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +112,7 @@ export function createFrameTemplateEditor(
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "기본 도각으로 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "기본 도각으로 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -134,16 +127,10 @@ export function createFrameTemplateEditor(
|
||||
const drawing = await options.requestCadDrawing();
|
||||
await saveFrameTemplate(options.projectId, drawing);
|
||||
options.onSaved();
|
||||
showToast(
|
||||
"도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.",
|
||||
"success",
|
||||
);
|
||||
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 저장하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 저장하지 못했습니다.", "error");
|
||||
} finally {
|
||||
finishButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const drawingButton = (
|
||||
drawing: DesignDrawingItem,
|
||||
label: string,
|
||||
): HTMLButtonElement => {
|
||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
@@ -175,8 +172,7 @@ function buildDrawingSidePanel(
|
||||
const button = drawingButton(drawing, group.label);
|
||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||
button.dataset.pending = String(drawing.kind === "blank");
|
||||
if (drawing.kind === "blank")
|
||||
button.title = "준비 중 — 도각만 표시합니다";
|
||||
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
@@ -198,10 +194,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const GROUND_TYPE_LABEL: Record<
|
||||
CrossDesignInfo["ground_type"],
|
||||
keyof typeof ui_locales
|
||||
> = {
|
||||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||||
soil: "B06_Design_Ground_Soil",
|
||||
ripping_rock: "B06_Design_Ground_Ripping",
|
||||
blasting_rock: "B06_Design_Ground_Blasting",
|
||||
@@ -218,8 +211,7 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
|
||||
return "없음";
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
||||
if (ditch.type === "l_type")
|
||||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
@@ -239,10 +231,7 @@ function infoRow(label: string, value: string): HTMLElement {
|
||||
}
|
||||
|
||||
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
|
||||
function buildDesignInfoPanel(
|
||||
title: string,
|
||||
design: CrossDesignInfo | null,
|
||||
): HTMLElement {
|
||||
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-info";
|
||||
const heading = document.createElement("div");
|
||||
@@ -252,9 +241,7 @@ function buildDesignInfoPanel(
|
||||
const confirmed = design?.status === "confirmed";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
||||
badge.textContent = confirmed
|
||||
? L("B07_Info_Confirmed")
|
||||
: L("B07_Info_Provisional");
|
||||
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
|
||||
heading.append(stationName, badge);
|
||||
panel.append(heading);
|
||||
|
||||
@@ -276,9 +263,7 @@ function buildDesignInfoPanel(
|
||||
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
||||
infoRow(
|
||||
L("B07_Info_DitchSide"),
|
||||
design.ditch_side === "left"
|
||||
? L("B06_Design_Ditch_Left")
|
||||
: L("B06_Design_Ditch_Right"),
|
||||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -288,10 +273,7 @@ function buildDesignInfoPanel(
|
||||
planTitle.textContent = L("B07_Info_Plan_Title");
|
||||
plan.append(
|
||||
planTitle,
|
||||
infoRow(
|
||||
L("B07_Info_DesignElevation"),
|
||||
`${design.design_elevation_m.toFixed(2)}m`,
|
||||
),
|
||||
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
|
||||
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
||||
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
||||
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
||||
@@ -317,10 +299,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
fetchWorkflowState(projectId),
|
||||
fetchDesignDrawingList(projectId),
|
||||
]);
|
||||
if (workflowResult.status === "fulfilled")
|
||||
workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled")
|
||||
drawings = drawingResult.value.drawings;
|
||||
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
|
||||
else
|
||||
drawingError =
|
||||
drawingResult.reason instanceof Error
|
||||
@@ -350,25 +330,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) &&
|
||||
drawings.filter(isCross).every((item) => item.confirmed);
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
|
||||
const updateInfoPanel = (
|
||||
drawing: DesignDrawingItem,
|
||||
response: DesignDrawingResponse,
|
||||
): void => {
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const title = drawing.label;
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(title, response.design ?? null),
|
||||
);
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -406,9 +380,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
) ?? undefined;
|
||||
|
||||
const highlightActive = (drawingId: string) => {
|
||||
drawingListEl
|
||||
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button")
|
||||
.forEach((item) => {
|
||||
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
};
|
||||
@@ -449,24 +421,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||||
|
||||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||||
const requestDrawing = (
|
||||
drawing: DesignDrawingItem,
|
||||
): Promise<DesignDrawingResponse> => {
|
||||
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
|
||||
const cached = drawingCache.get(drawing.id);
|
||||
if (cached) return cached;
|
||||
const request = (async () => {
|
||||
const response = await fetchDesignDrawing(
|
||||
projectId as string,
|
||||
drawing.id,
|
||||
);
|
||||
const response = await fetchDesignDrawing(projectId as string, drawing.id);
|
||||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||||
if (drawing.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(
|
||||
projectId as string,
|
||||
response.route_id,
|
||||
response.drawing,
|
||||
);
|
||||
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
|
||||
}
|
||||
return response;
|
||||
})().catch((error) => {
|
||||
@@ -522,9 +485,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} catch (error) {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "CAD 도면을 불러오지 못했습니다.";
|
||||
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
@@ -549,10 +510,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_SAVE_REQUEST_MESSAGE },
|
||||
window.location.origin,
|
||||
);
|
||||
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
@@ -596,9 +554,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "현재 도면을 확정하지 못했습니다.",
|
||||
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -628,9 +584,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -650,11 +604,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.prepend(frameEditor.banner);
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frame.contentWindow
|
||||
)
|
||||
return;
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
@@ -672,8 +622,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration =
|
||||
message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
@@ -693,8 +642,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
cadHost.dataset.error =
|
||||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.loading = "false";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
@@ -704,11 +652,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (
|
||||
message.type === CAD_SAVE_RESPONSE_MESSAGE &&
|
||||
message.drawing &&
|
||||
resolveSave
|
||||
) {
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({
|
||||
@@ -718,11 +662,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
);
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
@@ -744,10 +684,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
onStepClick: (stepIndex) => {
|
||||
if (!projectId) return;
|
||||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||||
showToast(
|
||||
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
|
||||
"warning",
|
||||
);
|
||||
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
|
||||
@@ -116,11 +116,7 @@
|
||||
|
||||
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
|
||||
.b07-drawing-button[data-confirmed="true"] {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-success) 35%,
|
||||
var(--color-border)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
wipeoutToolStateMachine,
|
||||
xlineToolStateMachine,
|
||||
} from '../tools/draw/construction-tools';
|
||||
import {
|
||||
divideToolStateMachine,
|
||||
measureLengthToolStateMachine,
|
||||
} from '../tools/draw/divide-tools';
|
||||
import { divideToolStateMachine, measureLengthToolStateMachine } from '../tools/draw/divide-tools';
|
||||
import {
|
||||
boundaryToolStateMachine,
|
||||
gradientToolStateMachine,
|
||||
|
||||
@@ -3,12 +3,7 @@ import type { FC } from 'react';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { PropertiesEditor } from './PropertiesEditor';
|
||||
import { getInspectorTab, openInspector } from './ui-state';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setLayers,
|
||||
} from '../state';
|
||||
import { getActiveLayerId, getLayers, setActiveLayerId, setLayers } from '../state';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
collapsed: boolean;
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import {
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
|
||||
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
|
||||
|
||||
interface PropertiesEditorProps {
|
||||
@@ -139,9 +134,7 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
</div>
|
||||
<div>
|
||||
<dt>시작점</dt>
|
||||
<dd>
|
||||
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
|
||||
</dd>
|
||||
<dd>{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>그룹</dt>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Arc, Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {EPSILON} from "../App.consts.ts";
|
||||
import { EPSILON } from '../App.consts.ts';
|
||||
import { ArcEntity } from './ArcEntity.ts';
|
||||
|
||||
describe('ArcEntity.distanceTo', () => {
|
||||
|
||||
@@ -89,7 +89,13 @@ export class HatchEntity implements Entity {
|
||||
}
|
||||
|
||||
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
|
||||
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
||||
drawController.setLineStyles(
|
||||
highlighted,
|
||||
selected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
for (let index = 1; index < this.points.length; index++) {
|
||||
drawController.drawLine(this.points[index - 1], this.points[index]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {LineEntity} from "./LineEntity.ts";
|
||||
import {TO_DEGREES} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { LineEntity } from './LineEntity.ts';
|
||||
import { TO_DEGREES } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
describe('getAngle', () => {
|
||||
it('should return 0 for a horizontal line', () => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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 {
|
||||
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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -29,20 +29,19 @@ export function calculateAngleGuidesAndSnapPoints() {
|
||||
const entities = queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
maxSnapDistance * 2,
|
||||
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
|
||||
maxSnapDistance * 2
|
||||
).filter((entity) => !getLayerById(entity.layerId)?.isLocked);
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
hoveredSnapPoint =>
|
||||
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
|
||||
)
|
||||
: [];
|
||||
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
|
||||
);
|
||||
|
||||
if (getShouldDrawHelpers()) {
|
||||
@@ -51,7 +50,7 @@ export function calculateAngleGuidesAndSnapPoints() {
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance,
|
||||
maxSnapDistance
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: a 100x100 square
|
||||
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.
|
||||
@@ -27,7 +27,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper: 200x200
|
||||
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).
|
||||
@@ -43,7 +43,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
100, // wrapper: 200x100
|
||||
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.
|
||||
@@ -64,7 +64,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
100, // wrapper: 300x100
|
||||
100 // wrapper: 300x100
|
||||
);
|
||||
// Contained AR = 200/50 = 4:1
|
||||
// Wrapper AR = 300/100 = 3:1
|
||||
@@ -86,7 +86,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper
|
||||
200 // wrapper
|
||||
);
|
||||
// Center as a single point at (100,100)
|
||||
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
|
||||
@@ -101,7 +101,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300, // wrapper
|
||||
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,
|
||||
@@ -118,7 +118,7 @@ describe('containRectangle', () => {
|
||||
10,
|
||||
20,
|
||||
110,
|
||||
220, // wrapper: 100x200
|
||||
220 // wrapper: 100x200
|
||||
);
|
||||
// Wrapper size: 100x200
|
||||
// Contained size: 10x30
|
||||
@@ -143,7 +143,7 @@ describe('containRectangle', () => {
|
||||
-100,
|
||||
-50,
|
||||
100,
|
||||
50, // wrapper: 200 wide x 100 tall
|
||||
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.
|
||||
@@ -161,7 +161,7 @@ describe('containRectangle', () => {
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: 100 wide x 100 tall
|
||||
100 // wrapper: 100 wide x 100 tall
|
||||
);
|
||||
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export function containRectangle(
|
||||
wrapperRectMinX: number,
|
||||
wrapperRectMinY: number,
|
||||
wrapperRectMaxX: number,
|
||||
wrapperRectMaxY: number,
|
||||
wrapperRectMaxY: number
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
// Calculate the width and height of the wrapper rectangle
|
||||
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
|
||||
@@ -29,10 +29,7 @@ export function containRectangle(
|
||||
}
|
||||
|
||||
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
wrapperWidth / containedWidth,
|
||||
wrapperHeight / containedHeight,
|
||||
);
|
||||
const scale = Math.min(wrapperWidth / containedWidth, wrapperHeight / containedHeight);
|
||||
|
||||
// Compute final displayed dimensions
|
||||
const displayWidth = containedWidth * scale;
|
||||
|
||||
@@ -5,17 +5,13 @@ describe('svgPathToSegments', () => {
|
||||
it('should handle simple move and line commands', () => {
|
||||
const path = 'M 10 10 L 20 20';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle relative line commands', () => {
|
||||
const path = 'M 10 10 l 10 10';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle horizontal and vertical lines', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Point} from "@flatten-js/core";
|
||||
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 { 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 () => {
|
||||
|
||||
@@ -13,32 +13,26 @@ import { sortPointsOnArc } from './sort-points-on-arc';
|
||||
export function findNeighboringPointsOnArc(
|
||||
clickedPointOnShape: Point,
|
||||
arc: ArcEntity,
|
||||
pointsOnShape: Point[],
|
||||
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,
|
||||
(arc.getShape() as Arc).start
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnArc',
|
||||
);
|
||||
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
|
||||
],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,31 +13,25 @@ import { sortPointsOnCircle } from './sort-points-on-circle';
|
||||
export function findNeighboringPointsOnCircle(
|
||||
clickedPointOnShape: Point,
|
||||
circle: CircleEntity,
|
||||
pointsOnShape: Point[],
|
||||
pointsOnShape: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnCircle(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(circle.getShape() as Circle).center,
|
||||
(circle.getShape() as Circle).center
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnCircle',
|
||||
);
|
||||
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
|
||||
],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,24 +14,19 @@ export function findNeighboringPointsOnLine(
|
||||
clickedPointOnLine: Point,
|
||||
lineStartPoint: Point,
|
||||
lineEndPoint: Point,
|
||||
pointsOnLine: 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)],
|
||||
uniqWith([lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint], isPointEqual),
|
||||
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)]
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnLine, point),
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnLine, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnLine',
|
||||
);
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnLine');
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -28,8 +28,7 @@ export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE
|
||||
while (remaining.length) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const index = remaining.findIndex(
|
||||
(chain) =>
|
||||
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
(chain) => near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
);
|
||||
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
|
||||
const [chain] = remaining.splice(index, 1);
|
||||
|
||||
@@ -33,7 +33,10 @@ function sampleCircle(circle: Circle, segments: number): Point[] {
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
points.push(
|
||||
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
|
||||
new Point(
|
||||
circle.center.x + circle.r * Math.cos(angle),
|
||||
circle.center.y + circle.r * Math.sin(angle)
|
||||
)
|
||||
);
|
||||
}
|
||||
return points;
|
||||
@@ -48,9 +51,7 @@ export function dedupeConsecutive(points: Point[]): Point[] {
|
||||
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const children = (entity as PolyLineEntity).getEntities();
|
||||
return dedupeConsecutive(
|
||||
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
|
||||
);
|
||||
return dedupeConsecutive(children.flatMap((child) => sampleEntityPoints(child, curveSegments)));
|
||||
}
|
||||
|
||||
const shape = entity.getShape();
|
||||
|
||||
@@ -71,7 +71,9 @@ export function regularPolygonPoints(center: Point, vertex: Point, sides: number
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const angle = startAngle + (2 * Math.PI * index) / count;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
points.push(points[0].clone());
|
||||
return points;
|
||||
@@ -101,11 +103,7 @@ export function ellipsePoints(
|
||||
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
||||
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
||||
if (controlPoints.length < 3) return [...controlPoints];
|
||||
const extended = [
|
||||
controlPoints[0],
|
||||
...controlPoints,
|
||||
controlPoints[controlPoints.length - 1],
|
||||
];
|
||||
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < extended.length - 2; index++) {
|
||||
const p0 = extended[index - 1];
|
||||
@@ -158,7 +156,9 @@ function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { times } from './times';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
export function getAngleGuideLines(
|
||||
firstPoint: Point,
|
||||
angleStep: number,
|
||||
): LineEntity[] {
|
||||
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 => {
|
||||
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(),
|
||||
const angleLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
new Point(
|
||||
firstPoint.x - 10000 * (x - firstPoint.x),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y)
|
||||
),
|
||||
new Point(
|
||||
firstPoint.x + 10000 * (x - firstPoint.x),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y),
|
||||
),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y)
|
||||
)
|
||||
);
|
||||
angleLine.lineColor = ANGLE_GUIDES_COLOR;
|
||||
angleLine.lineDash = ANGLE_GUIDES_DASH;
|
||||
return angleLine
|
||||
return angleLine;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
|
||||
+3
-8
@@ -15,7 +15,7 @@ export function convertSvgToPngBlob(
|
||||
svgLines: string[],
|
||||
width: number,
|
||||
height: number,
|
||||
margin: number,
|
||||
margin: number
|
||||
): Promise<Blob> {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -40,7 +40,7 @@ export function convertSvgToPngBlob(
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
@@ -57,12 +57,7 @@ export async function exportEntitiesToPngFile() {
|
||||
const entities = getEntities();
|
||||
|
||||
const svg = convertEntitiesToSvgString(entities);
|
||||
const pngDataBlob: Blob = await convertSvgToPngBlob(
|
||||
svg.svgLines,
|
||||
svg.width,
|
||||
svg.height,
|
||||
20,
|
||||
);
|
||||
const pngDataBlob: Blob = await convertSvgToPngBlob(svg.svgLines, svg.width, svg.height, 20);
|
||||
|
||||
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ 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 type { JsonDrawingFileDeserialized } from './export-entities-to-json.ts';
|
||||
|
||||
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
|
||||
const file = await getEntitiesAndLayersFromLocalStorage();
|
||||
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
export interface SvgParseResult {
|
||||
type: string
|
||||
children: Children[]
|
||||
type: string;
|
||||
children: Children[];
|
||||
}
|
||||
|
||||
export interface Children {
|
||||
type: string
|
||||
tagName: string
|
||||
properties: Record<string, string>
|
||||
children: Children[]
|
||||
metadata?: string
|
||||
type: string;
|
||||
tagName: string;
|
||||
properties: Record<string, string>;
|
||||
children: Children[];
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
+2
-4
@@ -3,10 +3,8 @@
|
||||
* Load the image data
|
||||
* Convert it to a base64 string
|
||||
*/
|
||||
export function importImageFromFile(
|
||||
file: File | null | undefined,
|
||||
): Promise<HTMLImageElement> {
|
||||
return new Promise<HTMLImageElement>(resolve => {
|
||||
export function importImageFromFile(file: File | null | undefined): Promise<HTMLImageElement> {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
if (!file) return;
|
||||
|
||||
const img = new Image();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,5 @@ import type { Point } from '@flatten-js/core';
|
||||
import { EPSILON } from '../App.consts';
|
||||
|
||||
export function isPointEqual(point1: Point, point2: Point): boolean {
|
||||
return (
|
||||
Math.abs(point1.x - point2.x) < EPSILON &&
|
||||
Math.abs(point1.y - point2.y) < EPSILON
|
||||
);
|
||||
return Math.abs(point1.x - point2.x) < EPSILON && Math.abs(point1.y - point2.y) < EPSILON;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {KeyboardEvent} from "react";
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
export function keyboardHandler(clickHandler: () => void) {
|
||||
return (evt: KeyboardEvent) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function mapNumberRange(
|
||||
startSourceRange: number,
|
||||
endSourceRange: number,
|
||||
startTargetRange: number,
|
||||
endTargetRange: number,
|
||||
endTargetRange: number
|
||||
): number {
|
||||
// Handle the case where source range has zero length
|
||||
if (startSourceRange === endSourceRange) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {LineEntity} from "../entities/LineEntity.ts";
|
||||
import type { LineEntity } from '../entities/LineEntity.ts';
|
||||
|
||||
export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) {
|
||||
const mirrorAngle = mirrorAxis.getAngle();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {describe, expect, it} from "vitest";
|
||||
import {Point} from "@flatten-js/core";
|
||||
import { 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 { LineEntity } from '../entities/LineEntity.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
describe("mirrorPointOverAxis", () => {
|
||||
describe('mirrorPointOverAxis', () => {
|
||||
it('should mirror if the axis is horizontal', () => {
|
||||
const point = new Point(100, 100);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50));
|
||||
@@ -13,7 +13,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirroredPoint.y).toBe(0);
|
||||
});
|
||||
|
||||
it("mirrors a point over a vertical axis", () => {
|
||||
it('mirrors a point over a vertical axis', () => {
|
||||
const point = new Point(3, 4);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -21,7 +21,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it("mirrors a point over the diagonal line y = x", () => {
|
||||
it('mirrors a point over the diagonal line y = x', () => {
|
||||
const point = new Point(3, 4);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -30,7 +30,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it("returns the same point if the point lies on the mirror axis", () => {
|
||||
it('returns the same point if the point lies on the mirror axis', () => {
|
||||
const point = new Point(1, 1);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
@@ -38,7 +38,7 @@ describe("mirrorPointOverAxis", () => {
|
||||
expect(mirrored.y).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("returns the original point when mirrored twice", () => {
|
||||
it('returns the original point when mirrored twice', () => {
|
||||
const point = new Point(5, 7);
|
||||
const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis
|
||||
const mirrored = mirrorPointOverAxis(point, axis);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Point, type Segment } from '@flatten-js/core';
|
||||
import type {LineEntity} from "../entities/LineEntity.ts";
|
||||
import type { LineEntity } from '../entities/LineEntity.ts';
|
||||
|
||||
export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) {
|
||||
const mirrorAxisSegment = mirrorAxis.getShape() as Segment;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
|
||||
export function rotatePoint(
|
||||
point: Point,
|
||||
rotateOrigin: Point,
|
||||
angle: number,
|
||||
): Point {
|
||||
export function rotatePoint(point: Point, rotateOrigin: Point, angle: number): Point {
|
||||
const vector = new Vector(rotateOrigin, point);
|
||||
const rotatedVector = vector.rotate(angle);
|
||||
return new Point(rotatedVector.x, rotatedVector.y);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
|
||||
export function scalePoint(
|
||||
point: Point,
|
||||
scaleOrigin: Point,
|
||||
scaleFactor: number,
|
||||
): Point {
|
||||
export function scalePoint(point: Point, scaleOrigin: Point, scaleFactor: number): Point {
|
||||
const vector = new Vector(scaleOrigin, point);
|
||||
const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1);
|
||||
return new Point(point.x + scaledVector.x, point.y + scaledVector.y);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import {
|
||||
getGridEnabled,
|
||||
getHighlightedEntityIds,
|
||||
setHighlightedEntityIds,
|
||||
} from '../state';
|
||||
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
|
||||
import { drawEntities } from './draw-functions';
|
||||
import { getSceneVersion } from './scene-version';
|
||||
import { queryEntitiesInBox } from './spatial-index';
|
||||
@@ -130,7 +126,11 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
|
||||
|
||||
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
|
||||
// bitmap would drag the grid along — always re-render while grid is on.
|
||||
if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
|
||||
if (
|
||||
paramsChanged ||
|
||||
getGridEnabled() ||
|
||||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
|
||||
) {
|
||||
rebuildScene(drawController);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,22 +12,20 @@ import { ArcEntity } from '../entities/ArcEntity';
|
||||
export function sortPointsOnArc(
|
||||
pointsOnArc: Point[],
|
||||
centerPoint: Point,
|
||||
startPoint: Point,
|
||||
startPoint: Point
|
||||
): Point[] {
|
||||
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
|
||||
|
||||
// Angles calculated from start point (0 degrees) and up
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => {
|
||||
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),
|
||||
angle: (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % (2 * Math.PI),
|
||||
};
|
||||
});
|
||||
return sortBy(pointsWithAngles, [
|
||||
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
|
||||
]).map(pointsWithAngle => pointsWithAngle.point);
|
||||
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
|
||||
(pointsWithAngle) => pointsWithAngle.point
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,17 +7,14 @@ import type { PointWithAngle } from './helpers.types';
|
||||
* @param pointsOnCircle
|
||||
* @param centerPoint
|
||||
*/
|
||||
export function sortPointsOnCircle(
|
||||
pointsOnCircle: Point[],
|
||||
centerPoint: Point,
|
||||
): Point[] {
|
||||
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => {
|
||||
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);
|
||||
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
|
||||
(pointsWithAngle) => pointsWithAngle.point
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export function times<T>(
|
||||
num: number,
|
||||
iterateeFunc: (i: number) => T = (i: number) => i as T,
|
||||
): T[] {
|
||||
export function times<T>(num: number, iterateeFunc: (i: number) => T = (i: number) => i as T): T[] {
|
||||
let i = 0;
|
||||
const items = [];
|
||||
while (i < num) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export function trackHoveredSnapPoint(
|
||||
worldHoveredSnapPoints: HoverPoint[],
|
||||
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
|
||||
maxHoverDistance: number,
|
||||
elapsedTime: number,
|
||||
elapsedTime: number
|
||||
) {
|
||||
if (!worldSnapPoint) {
|
||||
return;
|
||||
@@ -22,18 +22,14 @@ export function trackHoveredSnapPoint(
|
||||
|
||||
// Angle guide points should never be marked
|
||||
if (lastHoveredPoint) {
|
||||
if (
|
||||
pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) <
|
||||
maxHoverDistance
|
||||
) {
|
||||
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,
|
||||
milliSecondsHovered: lastHoveredPoint.milliSecondsHovered + elapsedTime,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
@@ -69,9 +65,6 @@ export function trackHoveredSnapPoint(
|
||||
];
|
||||
}
|
||||
|
||||
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(
|
||||
0,
|
||||
MAX_MARKED_SNAP_POINTS,
|
||||
);
|
||||
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(0, MAX_MARKED_SNAP_POINTS);
|
||||
setHoveredSnapPoints(newHoverSnapPointsTruncated);
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 {
|
||||
type BoundingBox,
|
||||
getBoundingBoxOfMultipleEntities,
|
||||
} from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import {
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
@@ -11,7 +14,13 @@ import {
|
||||
} 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 {
|
||||
DrawEvent,
|
||||
KeyboardEnterEvent,
|
||||
MouseClickEvent,
|
||||
StateEvent,
|
||||
ToolContext,
|
||||
} from './tool.types.ts';
|
||||
|
||||
export interface AlignContext extends ToolContext {}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Tool } from '../tools';
|
||||
import { createMachine } from 'xstate';
|
||||
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
|
||||
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 { LineEntity } from '../entities/LineEntity.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
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 { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
import { moveEntities } from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
@@ -144,10 +144,7 @@ export const copyToolStateMachine = createMachine(
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
CopyAction.RECORD_START_POINT,
|
||||
CopyAction.COPY_SELECTION_BEFORE_COPY,
|
||||
],
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
@@ -211,39 +208,31 @@ export const copyToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
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',
|
||||
);
|
||||
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(),
|
||||
);
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
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,
|
||||
endPointTemp
|
||||
);
|
||||
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
@@ -252,20 +241,16 @@ export const copyToolStateMachine = createMachine(
|
||||
},
|
||||
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[COPY] Calling copy selection without a start point',
|
||||
);
|
||||
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(),
|
||||
);
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// Switch the copied entities back from the ghost helper entities to the real entities
|
||||
@@ -292,5 +277,5 @@ export const copyToolStateMachine = createMachine(
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from '../../helpers/geometry/shape-points';
|
||||
import { addEntities, getActiveLayerId } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { arcEntity, lineEntity, pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
|
||||
import {
|
||||
arcEntity,
|
||||
lineEntity,
|
||||
pointEntity,
|
||||
polyLineEntity,
|
||||
styled,
|
||||
} from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const plineToolStateMachine = createSequenceTool({
|
||||
@@ -138,7 +144,11 @@ export const donutToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
|
||||
function donutEntities(
|
||||
innerDiameter: number,
|
||||
outerDiameter: number,
|
||||
center: Parameters<typeof pointEntity>[0]
|
||||
): Entity[] {
|
||||
const circles: Entity[] = [];
|
||||
for (const diameter of [innerDiameter, outerDiameter]) {
|
||||
if (diameter > 0) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
|
||||
import { toast } from 'react-toastify';
|
||||
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import {
|
||||
dividePoints,
|
||||
measurePoints,
|
||||
sampleEntityPoints,
|
||||
} from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pointEntity } from '../factories/entity-factory';
|
||||
|
||||
@@ -57,9 +57,7 @@ export const hatchToolStateMachine = createSequenceTool({
|
||||
export const gradientToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GRADIENT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
|
||||
],
|
||||
steps: [{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
@@ -80,9 +78,7 @@ export const gradientToolStateMachine = createSequenceTool({
|
||||
export const boundaryToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BOUNDARY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
|
||||
],
|
||||
steps: [{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { eraseCircleSegment, getAllIntersectionPoints } from './eraser-tool.helpers.ts';
|
||||
import { handleMouseClick } from './eraser-tool.ts';
|
||||
|
||||
describe('erase-tool', () => {
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
} 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 {
|
||||
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';
|
||||
@@ -164,31 +172,28 @@ export const imageImportToolStateMachine = createMachine(
|
||||
[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',
|
||||
'[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',
|
||||
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
if (
|
||||
isPointEqual(
|
||||
context.startPoint,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
(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 endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const containRectangle = getContainRectangleInsideRectangle(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
endPoint,
|
||||
endPoint
|
||||
);
|
||||
if (!containRectangle) {
|
||||
return;
|
||||
@@ -198,11 +203,11 @@ export const imageImportToolStateMachine = createMachine(
|
||||
context.imageElement,
|
||||
containRectangle.low,
|
||||
containRectangle.high,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const draggedRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
twoPointBoxToPolygon(context.startPoint, endPoint),
|
||||
twoPointBoxToPolygon(context.startPoint, endPoint)
|
||||
);
|
||||
setGhostHelperEntities([activeImage]);
|
||||
setAngleGuideEntities([draggedRectangle]);
|
||||
@@ -210,12 +215,12 @@ export const imageImportToolStateMachine = createMachine(
|
||||
[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',
|
||||
'[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',
|
||||
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,7 +228,7 @@ export const imageImportToolStateMachine = createMachine(
|
||||
context.imageElement.naturalWidth,
|
||||
context.imageElement.naturalHeight,
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
(event as MouseClickEvent).worldMouseLocation
|
||||
);
|
||||
|
||||
if (!containRectangle) {
|
||||
@@ -233,7 +238,7 @@ export const imageImportToolStateMachine = createMachine(
|
||||
const activeImage = new ImageEntity(
|
||||
getActiveLayerId(),
|
||||
context.imageElement,
|
||||
boxToPolygon(containRectangle),
|
||||
boxToPolygon(containRectangle)
|
||||
);
|
||||
addEntities([activeImage], true);
|
||||
},
|
||||
@@ -241,5 +246,5 @@ export const imageImportToolStateMachine = createMachine(
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -50,14 +50,7 @@ export const chamferToolStateMachine = createSequenceTool({
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(
|
||||
first,
|
||||
input.pick(2),
|
||||
second,
|
||||
input.pick(3),
|
||||
input.number(0),
|
||||
input.number(1)
|
||||
)
|
||||
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,14 +80,8 @@ export function filletLines(
|
||||
}
|
||||
|
||||
const tangentDistance = radius / Math.tan(angle / 2);
|
||||
const tangentA = new Point(
|
||||
corner.x + ua.x * tangentDistance,
|
||||
corner.y + ua.y * tangentDistance
|
||||
);
|
||||
const tangentB = new Point(
|
||||
corner.x + ub.x * tangentDistance,
|
||||
corner.y + ub.y * tangentDistance
|
||||
);
|
||||
const tangentA = new Point(corner.x + ua.x * tangentDistance, corner.y + ua.y * tangentDistance);
|
||||
const tangentB = new Point(corner.x + ub.x * tangentDistance, corner.y + ub.y * tangentDistance);
|
||||
|
||||
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
|
||||
const centerDistance = radius / Math.sin(angle / 2);
|
||||
|
||||
@@ -129,7 +129,11 @@ export const lengthenToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LENGTHEN,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
|
||||
{
|
||||
kind: 'number',
|
||||
instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.',
|
||||
defaultValue: 10,
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
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 { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
|
||||
export interface MoveContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
@@ -145,10 +145,7 @@ export const moveToolStateMachine = createMachine(
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
MoveAction.RECORD_START_POINT,
|
||||
MoveAction.COPY_SELECTION_BEFORE_MOVE,
|
||||
],
|
||||
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ESC: {
|
||||
@@ -214,39 +211,31 @@ export const moveToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
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',
|
||||
);
|
||||
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(),
|
||||
);
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y,
|
||||
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,
|
||||
endPointTemp
|
||||
);
|
||||
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
@@ -255,9 +244,7 @@ export const moveToolStateMachine = createMachine(
|
||||
},
|
||||
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[MOVE] Calling move selection without a start point',
|
||||
);
|
||||
throw new Error('[MOVE] Calling move selection without a start point');
|
||||
}
|
||||
|
||||
// Move the entities one final time
|
||||
@@ -265,7 +252,7 @@ export const moveToolStateMachine = createMachine(
|
||||
moveEntities(
|
||||
context.originalSelectedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// Switch the moved entities back from the ghost helper entities to the real entities
|
||||
@@ -294,5 +281,5 @@ export const moveToolStateMachine = createMachine(
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
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';
|
||||
@@ -117,10 +117,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
RotateAction.INIT_ROTATE_TOOL,
|
||||
],
|
||||
actions: [RotateAction.DESELECT_ENTITIES, RotateAction.INIT_ROTATE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
@@ -184,10 +181,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.ROTATE_SELECTION,
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
],
|
||||
actions: [RotateAction.ROTATE_SELECTION, RotateAction.DESELECT_ENTITIES],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
@@ -208,27 +202,22 @@ export const rotateToolStateMachine = createMachine(
|
||||
[RotateAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
setAngleGuideOriginPoint(
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
[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 => {
|
||||
[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
|
||||
@@ -242,53 +231,46 @@ export const rotateToolStateMachine = createMachine(
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map(entity => entity.clone()),
|
||||
),
|
||||
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',
|
||||
'[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()),
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
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',
|
||||
);
|
||||
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()),
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint,
|
||||
angleEndpoint
|
||||
);
|
||||
|
||||
// Switch the rotated entities back from the ghost helper entities to the real entities
|
||||
@@ -306,8 +288,7 @@ export const rotateToolStateMachine = createMachine(
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(
|
||||
({ context }): RotateContext => {
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
@@ -317,9 +298,8 @@ export const rotateToolStateMachine = createMachine(
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
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';
|
||||
@@ -122,10 +122,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [
|
||||
ScaleAction.DESELECT_ENTITIES,
|
||||
ScaleAction.INIT_SCALE_TOOL,
|
||||
],
|
||||
actions: [ScaleAction.DESELECT_ENTITIES, ScaleAction.INIT_SCALE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
@@ -189,10 +186,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ScaleAction.SCALE_SELECTION,
|
||||
ScaleAction.DESELECT_ENTITIES,
|
||||
],
|
||||
actions: [ScaleAction.SCALE_SELECTION, ScaleAction.DESELECT_ENTITIES],
|
||||
target: ScaleState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
@@ -213,25 +207,19 @@ export const scaleToolStateMachine = createMachine(
|
||||
[ScaleAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(
|
||||
({ context, event }) => {
|
||||
setAngleGuideOriginPoint(
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
);
|
||||
[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 }) => {
|
||||
}),
|
||||
[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();
|
||||
|
||||
@@ -246,53 +234,46 @@ export const scaleToolStateMachine = createMachine(
|
||||
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()),
|
||||
),
|
||||
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',
|
||||
'[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()),
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPointTemp,
|
||||
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',
|
||||
);
|
||||
throw new Error('[SCALE] Calling scale selection without some scale vector endpoints');
|
||||
}
|
||||
const scaleVectorEndPoint = (event as MouseClickEvent)
|
||||
.worldMouseLocation;
|
||||
const scaleVectorEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
// Scale the entities one final time
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map(entity => entity.clone()),
|
||||
context.originalSelectedEntities.map((entity) => entity.clone())
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPoint,
|
||||
scaleVectorEndPoint
|
||||
);
|
||||
|
||||
// Switch the scaled entities back from the ghost helper entities to the real entities
|
||||
@@ -322,5 +303,5 @@ export const scaleToolStateMachine = createMachine(
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
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 {
|
||||
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 {
|
||||
drawTempSelectionRectangle,
|
||||
handleFirstSelectionPoint,
|
||||
selectEntitiesInsideRectangle,
|
||||
} from './select-tool.helpers';
|
||||
|
||||
export interface SelectContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
@@ -45,8 +55,7 @@ export const selectToolStateMachine = createMachine(
|
||||
},
|
||||
},
|
||||
[SelectState.WAITING_FOR_FIRST_SELECT_POINT]: {
|
||||
description:
|
||||
'Select a line or select the first point of a selection rectangle',
|
||||
description: 'Select a line or select the first point of a selection rectangle',
|
||||
meta: {
|
||||
instructions: 'Select a line or start drawing a selection rectangle',
|
||||
},
|
||||
@@ -68,23 +77,19 @@ export const selectToolStateMachine = createMachine(
|
||||
},
|
||||
},
|
||||
[SelectState.CHECK_SELECTION]: {
|
||||
description:
|
||||
'Checking to select one line or start drawing a selection rectangle',
|
||||
description: 'Checking to select one line or start drawing a selection rectangle',
|
||||
meta: {
|
||||
instructions:
|
||||
'Select one line or start drawing a selection rectangle',
|
||||
instructions: 'Select one line or start drawing a selection rectangle',
|
||||
},
|
||||
always: [
|
||||
{
|
||||
// User started drawing a selection rectangle
|
||||
guard: ({ context }: { context: SelectContext }) =>
|
||||
!!context.startPoint,
|
||||
guard: ({ context }: { context: SelectContext }) => !!context.startPoint,
|
||||
target: SelectState.WAITING_FOR_SECOND_SELECT_POINT,
|
||||
},
|
||||
{
|
||||
// User clicked on an entity
|
||||
guard: ({ context }: { context: SelectContext }) =>
|
||||
!context.startPoint,
|
||||
guard: ({ context }: { context: SelectContext }) => !context.startPoint,
|
||||
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
|
||||
},
|
||||
],
|
||||
@@ -122,7 +127,7 @@ export const selectToolStateMachine = createMachine(
|
||||
HANDLE_FIRST_SELECT_POINT: assign(
|
||||
({ context, event }: { context: SelectContext; event: StateEvent }) => {
|
||||
return handleFirstSelectionPoint(context, event as MouseClickEvent);
|
||||
},
|
||||
}
|
||||
),
|
||||
DRAW_TEMP_SELECTION_RECTANGLE: ({
|
||||
context,
|
||||
@@ -133,13 +138,11 @@ export const selectToolStateMachine = createMachine(
|
||||
}) => {
|
||||
if (!context.startPoint) {
|
||||
// assert
|
||||
throw new Error(
|
||||
'[SELECT] Calling drawTempSelectionRectangle without startPoint set',
|
||||
);
|
||||
throw new Error('[SELECT] Calling drawTempSelectionRectangle without startPoint set');
|
||||
}
|
||||
drawTempSelectionRectangle(
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
);
|
||||
},
|
||||
SELECT_ENTITIES_INSIDE_RECTANGLE: ({
|
||||
@@ -151,14 +154,12 @@ export const selectToolStateMachine = createMachine(
|
||||
}) => {
|
||||
if (!context.startPoint) {
|
||||
//
|
||||
throw new Error(
|
||||
'[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point',
|
||||
);
|
||||
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).holdingCtrl
|
||||
// (event as MouseClickEvent).holdingShift,
|
||||
);
|
||||
setGhostHelperEntities([]);
|
||||
@@ -176,5 +177,5 @@ export const selectToolStateMachine = createMachine(
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -21,11 +21,7 @@ export interface TypedCommandEvent {
|
||||
|
||||
export interface ToolHandler {
|
||||
handleToolActivate(): void;
|
||||
handleToolClick(
|
||||
worldMouseLocation: Point,
|
||||
holdingCtrl: boolean,
|
||||
holdingShift: boolean,
|
||||
): void;
|
||||
handleToolClick(worldMouseLocation: Point, holdingCtrl: boolean, holdingShift: boolean): void;
|
||||
handleToolTypedCommand(command: string): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -22,8 +22,7 @@ test('Draw circle', async () => {
|
||||
|
||||
const circleEntity = entities[0];
|
||||
expect(circleEntity.getType()).toBe(EntityName.Circle);
|
||||
const circleJson =
|
||||
(await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
|
||||
const circleJson = (await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
|
||||
|
||||
expect(circleJson.lineColor).toBe('#fff');
|
||||
expect(circleJson.lineWidth).toBe(1);
|
||||
@@ -33,7 +32,5 @@ test('Draw circle', async () => {
|
||||
|
||||
const diffX = 424 - 257;
|
||||
const diffY = 366 - 325;
|
||||
expect(circleJson.shapeData.radius).toBe(
|
||||
Math.sqrt(diffX * diffX + diffY * diffY),
|
||||
);
|
||||
expect(circleJson.shapeData.radius).toBe(Math.sqrt(diffX * diffX + diffY * diffY));
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ 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 {
|
||||
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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-43
@@ -35,11 +35,9 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
|
||||
constructor(
|
||||
private context: CanvasRenderingContext2D | null,
|
||||
private canvasSize: Point,
|
||||
private canvasSize: Point
|
||||
) {
|
||||
this.worldMouseLocation = this.targetToWorld(
|
||||
new Point(canvasSize.x / 2, canvasSize.y / 2),
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,7 +79,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
public panScreen(screenOffsetX: number, screenOffsetY: number) {
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x - screenOffsetX / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,8 +91,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
public zoomScreen(deltaY: number) {
|
||||
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
|
||||
const newScreenScale =
|
||||
this.getScreenScale() *
|
||||
(1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
|
||||
this.getScreenScale() * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
|
||||
this.setScreenScale(newScreenScale);
|
||||
|
||||
// now get the location of the cursor in world space again
|
||||
@@ -105,12 +102,8 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
|
||||
// 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),
|
||||
this.screenOffset.x + (worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x),
|
||||
this.screenOffset.y + (worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,15 +117,15 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
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,
|
||||
),
|
||||
this.canvasSize.y
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,15 +153,15 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
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,
|
||||
),
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +174,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash: number[] = [],
|
||||
dash: number[] = []
|
||||
) {}
|
||||
|
||||
public setFillStyles(fillColor: string) {}
|
||||
@@ -200,10 +193,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* @param screenStartPoint
|
||||
* @param screenEndPoint
|
||||
*/
|
||||
public drawLineScreen(
|
||||
screenStartPoint: Point,
|
||||
screenEndPoint: Point,
|
||||
): void {}
|
||||
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {}
|
||||
|
||||
/**
|
||||
* Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI
|
||||
@@ -218,7 +208,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean,
|
||||
counterClockWise: boolean
|
||||
) {}
|
||||
|
||||
public drawArcScreen(
|
||||
@@ -226,7 +216,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
screenRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean,
|
||||
counterClockWise: boolean
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -245,7 +235,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {},
|
||||
}> = {}
|
||||
): void {}
|
||||
|
||||
/**
|
||||
@@ -264,7 +254,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {},
|
||||
}> = {}
|
||||
): void {}
|
||||
|
||||
/**
|
||||
@@ -282,16 +272,10 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: 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
|
||||
@@ -301,13 +285,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* @param height
|
||||
* @param color
|
||||
*/
|
||||
public fillRectScreen(
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) {}
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {}
|
||||
|
||||
/**
|
||||
* Fill polygon with color
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { CANVAS_HEIGHT } from '../../helpers/tests.consts';
|
||||
import eraserRecording from './eraser.recording.json';
|
||||
|
||||
test('Draw circle and line and erase part of circle', async () => {
|
||||
|
||||
@@ -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,12 +21,15 @@ 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"
|
||||
@@ -53,7 +58,9 @@ def convert_shp_to_gpkg():
|
||||
|
||||
# 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:
|
||||
@@ -73,12 +80,21 @@ def convert_shp_to_gpkg():
|
||||
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:
|
||||
@@ -92,5 +108,6 @@ def convert_shp_to_gpkg():
|
||||
print(f"★ 파일 위치: {gpkg_output_path}")
|
||||
print("==================================================")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
convert_shp_to_gpkg()
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
# -*- 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)
|
||||
|
||||
|
||||
# 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"))
|
||||
|
||||
@@ -29,26 +37,35 @@ Q = {
|
||||
"산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙",
|
||||
"산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률",
|
||||
"산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령",
|
||||
"산림보호법": "산림보호법", "산지관리법": "산지관리법",
|
||||
"자연환경보전법": "자연환경보전법", "자연재해대책법": "자연재해대책법",
|
||||
"산림보호법": "산림보호법",
|
||||
"산지관리법": "산지관리법",
|
||||
"자연환경보전법": "자연환경보전법",
|
||||
"자연재해대책법": "자연재해대책법",
|
||||
"환경영향평가법": "환경영향평가법",
|
||||
"보조금 관리에 관한 법률": "보조금 관리에 관한 법률",
|
||||
"국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률",
|
||||
"국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령",
|
||||
"지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률",
|
||||
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
|
||||
"도로법": "도로법", "농어촌도로정비법": "농어촌도로 정비법",
|
||||
"도로법": "도로법",
|
||||
"농어촌도로정비법": "농어촌도로 정비법",
|
||||
"국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률",
|
||||
"도로명주소법": "도로명주소법", "도로명주소법 시행령": "도로명주소법 시행령",
|
||||
"도로명주소법": "도로명주소법",
|
||||
"도로명주소법 시행령": "도로명주소법 시행령",
|
||||
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
|
||||
"산업안전보건법": "산업안전보건법", "산업재해보상보험법": "산업재해보상보험법",
|
||||
"산업안전보건법": "산업안전보건법",
|
||||
"산업재해보상보험법": "산업재해보상보험법",
|
||||
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
|
||||
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
|
||||
"고용보험법 시행령": "고용보험법 시행령",
|
||||
"국민건강보험법": "국민건강보험법", "국민연금법": "국민연금법",
|
||||
"노인장기요양보험법": "노인장기요양보험법", "노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
|
||||
"부가가치세법": "부가가치세법", "근로기준법": "근로기준법",
|
||||
"산업표준화법": "산업표준화법", "전자서명법": "전자서명법",
|
||||
"국민건강보험법": "국민건강보험법",
|
||||
"국민연금법": "국민연금법",
|
||||
"노인장기요양보험법": "노인장기요양보험법",
|
||||
"노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
|
||||
"부가가치세법": "부가가치세법",
|
||||
"근로기준법": "근로기준법",
|
||||
"산업표준화법": "산업표준화법",
|
||||
"전자서명법": "전자서명법",
|
||||
"공동주택관리법": "공동주택관리법",
|
||||
"임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정",
|
||||
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정",
|
||||
@@ -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)}건 생성")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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,27 +53,49 @@ 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 = [
|
||||
@@ -84,7 +119,9 @@ RULES = [
|
||||
"수치지도 작성 작업규칙",
|
||||
"비산분진 발생원 시설관리기준",
|
||||
"건설공사 감독자 업무 지침",
|
||||
"콘크리트 표준시방서", "도로공사 표준시방서", "임도시설공사 표준시방서",
|
||||
"콘크리트 표준시방서",
|
||||
"도로공사 표준시방서",
|
||||
"임도시설공사 표준시방서",
|
||||
]
|
||||
|
||||
res = {}
|
||||
@@ -94,7 +131,8 @@ 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({
|
||||
rows.append(
|
||||
{
|
||||
"명": txt(node, "법령명한글", "행정규칙명"),
|
||||
"약칭": txt(node, "법령약칭명"),
|
||||
"종류": txt(node, "법령구분명", "행정규칙종류"),
|
||||
@@ -103,9 +141,13 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R
|
||||
"시행": 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)
|
||||
|
||||
@@ -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",
|
||||
(
|
||||
"노임단가_건설업_대한건설협회",
|
||||
"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",
|
||||
"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.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/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"),
|
||||
"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)")
|
||||
|
||||
|
||||
|
||||
@@ -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("*")):
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
법령 조문의 <img> 안에 있던 박스 드로잉 표가 이미지 로컬화 후 텍스트로 남는데,
|
||||
│로 열을 구분하므로 md 표로 복원한다. 각 표에는 대응 ![그림]도 이미 있다.
|
||||
"""
|
||||
|
||||
import re, sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,20 +13,25 @@ BORDER = set("┌┬┐├┼┤└┴┘─━┏┳┓┣╋┫┗┻┛│┃
|
||||
VBAR = "│┃|"
|
||||
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"):
|
||||
|
||||
@@ -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\b([^>]*?)/?>')
|
||||
IMG = re.compile(r"<img\b([^>]*?)/?>")
|
||||
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 파일: <img> → 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*</img>\s*$', ">", new, flags=re.M)
|
||||
new = re.sub(r"^>?\s*</img>\s*$", ">", new, flags=re.M)
|
||||
new = new.replace("</img>", "")
|
||||
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):
|
||||
|
||||
@@ -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"))
|
||||
@@ -89,10 +94,17 @@ def byl_link_map(folder):
|
||||
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,6 +117,7 @@ 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 # .../별표 또는 .../첨부
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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
|
||||
@@ -81,6 +92,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")):
|
||||
continue
|
||||
try:
|
||||
import pymupdf
|
||||
|
||||
t = "".join(pg.get_text() for pg in pymupdf.open(pdf))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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
|
||||
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"<br\s*/?>", " ", 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"<caption[^>]*>(.*?)</caption>", 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 ""
|
||||
@@ -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}")
|
||||
@@ -189,5 +216,6 @@ def run():
|
||||
for n, t, o in summary:
|
||||
print(f" {o}/{t} {n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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"<script.*?</script>", "", h, flags=re.S)
|
||||
h = re.sub(r"<style.*?</style>", "", h, flags=re.S)
|
||||
@@ -46,10 +56,12 @@ 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("|기본정보|")
|
||||
@@ -72,12 +84,25 @@ def parse(ks, h):
|
||||
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()})
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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 해제) 레코드 파싱해
|
||||
@@ -30,6 +38,7 @@ HWPTAG_CTRL_HEADER = HWPTAG_BEGIN + 55 # 0x47
|
||||
HWPTAG_LIST_HEADER = HWPTAG_BEGIN + 56 # 0x48
|
||||
HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d
|
||||
|
||||
|
||||
def is_compressed(ole):
|
||||
with ole.openstream("FileHeader") as f:
|
||||
data = f.read()
|
||||
@@ -37,6 +46,7 @@ def is_compressed(ole):
|
||||
flags = struct.unpack("<I", data[36:40])[0]
|
||||
return bool(flags & 1)
|
||||
|
||||
|
||||
def records(buf):
|
||||
i, n = 0, len(buf)
|
||||
while i + 4 <= n:
|
||||
@@ -51,11 +61,13 @@ def records(buf):
|
||||
yield tag, level, buf[i : i + size]
|
||||
i += size
|
||||
|
||||
|
||||
# 인라인 확장 제어문자(뒤에 14 WCHAR = 28바이트 추가로 따라옴)
|
||||
EXT_CTRL = {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 14, 15, 16, 17, 18, 21, 22, 23}
|
||||
# 인라인 문자 제어(그 자체로 1 WCHAR)
|
||||
INLINE = {0, 10, 13, 24, 25, 26, 27, 28, 29, 30, 31}
|
||||
|
||||
|
||||
def para_text(data):
|
||||
out = []
|
||||
i, n = 0, len(data)
|
||||
@@ -73,6 +85,7 @@ def para_text(data):
|
||||
i += 2
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def extract(path):
|
||||
"""PARA_TEXT 문단만 평문 리스트로(하위호환)."""
|
||||
ole = olefile.OleFileIO(path)
|
||||
@@ -89,6 +102,7 @@ def extract(path):
|
||||
ole.close()
|
||||
return paras
|
||||
|
||||
|
||||
def _table_md(cells, ncols):
|
||||
"""셀 텍스트 리스트를 nCols 기준으로 md 표로."""
|
||||
cells = [re.sub(r"\s+", " ", (c or "").replace("|", "/")).strip() for c in cells]
|
||||
@@ -102,6 +116,7 @@ def _table_md(cells, ncols):
|
||||
out.append("| " + " | ".join(r) + " |")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def extract_items(path):
|
||||
"""(kind, val) 아이템 리스트. kind='text'|'table'. 표를 복원한다."""
|
||||
ole = olefile.OleFileIO(path)
|
||||
@@ -160,6 +175,7 @@ def extract_items(path):
|
||||
ole.close()
|
||||
return items
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
paras = extract(sys.argv[1])
|
||||
text = "\n".join(p for p in paras if p)
|
||||
|
||||
@@ -6,15 +6,18 @@ HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생
|
||||
- 단일셀 표(글상자) → 인용블록(줄바꿈 보존)
|
||||
- 번호체계(제N장 / N-N-N. / 1. / 가. / (1) / ①) → 헤딩·중첩 리스트
|
||||
"""
|
||||
|
||||
import re, sys, zipfile
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
||||
|
||||
|
||||
def _local(tag):
|
||||
return tag.split("}")[-1]
|
||||
|
||||
|
||||
def para_text(p):
|
||||
buf = []
|
||||
for el in p.iter():
|
||||
@@ -27,6 +30,7 @@ def para_text(p):
|
||||
buf.append("\n")
|
||||
return "".join(buf)
|
||||
|
||||
|
||||
def cell_paras(tc):
|
||||
parts = []
|
||||
for sub in tc.iter(f"{NS}p"):
|
||||
@@ -35,9 +39,11 @@ def cell_paras(tc):
|
||||
parts.append(s)
|
||||
return parts
|
||||
|
||||
|
||||
def cell_text(tc):
|
||||
return " ".join(cell_paras(tc)).replace("|", "/")
|
||||
|
||||
|
||||
def table_md(tbl):
|
||||
"""(kind, value) 반환. kind = 'table' | 'box' | 'text'."""
|
||||
rows = [tr.findall(f"{NS}tc") for tr in tbl.findall(f"{NS}tr")]
|
||||
@@ -57,6 +63,7 @@ def table_md(tbl):
|
||||
out.append("| " + " | ".join(c.replace("\n", "<br>") 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,6 +84,7 @@ 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.)
|
||||
@@ -88,6 +97,7 @@ MARKERS = [
|
||||
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
|
||||
]
|
||||
|
||||
|
||||
def marker(s):
|
||||
for k, rx in MARKERS:
|
||||
m = rx.match(s)
|
||||
@@ -95,6 +105,7 @@ 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)
|
||||
@@ -117,15 +128,20 @@ 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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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}](<KCS/{p.name}>)")
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = ["# 이미지 목록 — 표 변환 검토용", "",
|
||||
L = [
|
||||
"# 이미지 목록 — 표 변환 검토용",
|
||||
"",
|
||||
f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.",
|
||||
"> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", "",
|
||||
f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", "",
|
||||
"> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", "",
|
||||
"> 체크한 이미지를 알려주면 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}")
|
||||
|
||||
@@ -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("[[]압축[]]*"):
|
||||
|
||||
@@ -5,24 +5,33 @@
|
||||
- 본문은 법령 번호체계(Ⅰ./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)
|
||||
|
||||
|
||||
# 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))
|
||||
@@ -41,6 +50,7 @@ MARKERS = [
|
||||
]
|
||||
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,14 +71,18 @@ 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 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등)
|
||||
|
||||
|
||||
def _images(page, pno, doc, picdir, stem):
|
||||
"""페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환."""
|
||||
out = []
|
||||
@@ -94,6 +109,7 @@ def _images(page, pno, doc, picdir, stem):
|
||||
out.append((r, f""))
|
||||
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,8 +128,11 @@ 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)
|
||||
@@ -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,12 +187,12 @@ 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", "<br>")
|
||||
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)
|
||||
@@ -230,8 +254,13 @@ def convert(pdf_path):
|
||||
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": ""}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +54,9 @@ 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("-"):
|
||||
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):
|
||||
@@ -59,6 +66,7 @@ def check_tables(text):
|
||||
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,6 +77,7 @@ 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 = []
|
||||
@@ -77,16 +86,24 @@ def check_linebreak(text):
|
||||
s = lines[i].strip()
|
||||
prev = lines[i - 1].strip()
|
||||
# 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님)
|
||||
if s.startswith("|") and prev and not prev.startswith("|") and not prev.startswith("#") and not prev.startswith(">"):
|
||||
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 = []
|
||||
@@ -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()
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약(장 수)이 목차와 일치하는지 확인
|
||||
- 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(값·표는 정상) — W5 공백 기준 예외로 기록
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -26,7 +27,12 @@ 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)]
|
||||
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"
|
||||
body = (
|
||||
"## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
|
||||
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
|
||||
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n"
|
||||
"> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), "
|
||||
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table)
|
||||
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\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"
|
||||
hdr = (
|
||||
f"# {sec[3:]} 제{n}장 {nm}\n\n"
|
||||
f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n"
|
||||
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\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)} 장")
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user