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

This commit is contained in:
2026-10-20 11:50:31 +09:00
205 changed files with 9479 additions and 1708 deletions
-2
View File
@@ -2,5 +2,3 @@
B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary
B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary
# graphify 그래프 — 줄끝을 바꾸면 서명(.sig)이 깨지고 창마다 헛 diff 가 남는다.
docs/wiki/graphify-out/** -text
-1
View File
@@ -7,4 +7,3 @@ dist/
venv/
storage/
tmp/
graphify-out/
+1 -1
View File
@@ -74,7 +74,7 @@ export const STATE_REGISTRY = {
"routeedit-locks": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */
"routeedit-terrain": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 측점 스냅 간격(0.1 · 1 · 10 · station) */
/** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */
"routeedit-snap-step": { bucket: "pref", scope: "project" },
"masshaul-open": {
bucket: "pref",
+5 -22
View File
@@ -2,9 +2,8 @@
* design_dock.ts
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
*
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**:
* [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는
* 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**:
* [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
* ========================================================================== */
@@ -26,15 +25,13 @@ export interface DesignDockActions {
reset: DesignDockAction;
save: DesignDockAction;
confirm: DesignDockAction;
/** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */
switchView: DesignDockAction;
}
export interface DesignDock {
root: HTMLElement;
/** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
/** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
setActions: (actions: Partial<DesignDockActions>) => void;
/** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */
/** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */
setView: (view: DesignView) => void;
/** 「계산 중」 점 — 묶음 차례가 떠 있는 동안 켬(386-2). */
setBusy: (busy: boolean) => void;
@@ -44,11 +41,6 @@ function label(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */
export function switchViewLabel(view: DesignView): string {
return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile");
}
/**
* 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」
* 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
@@ -93,20 +85,13 @@ export function createDesignDock(
variant: "filled",
onClick: press("confirm"),
});
const switchButton = createButton({
label: switchViewLabel(view),
variant: "ghost",
onClick: press("switchView"),
});
resetButton.dataset.dockAction = "reset";
saveButton.dataset.dockAction = "save";
confirmButton.dataset.dockAction = "confirm";
switchButton.dataset.dockAction = "switchView";
const buttons = {
reset: resetButton,
save: saveButton,
confirm: confirmButton,
switchView: switchButton,
};
const sync = (): void => {
@@ -116,12 +101,10 @@ export function createDesignDock(
if (action.title) buttons[name].title = action.title;
else buttons[name].removeAttribute("title");
});
(switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent =
switchViewLabel(current);
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
};
row.append(resetButton, saveButton, confirmButton, switchButton);
row.append(resetButton, saveButton, confirmButton);
root.append(...above, divider, row, busy);
sync();
+7 -1
View File
@@ -58,6 +58,7 @@ import {
followPlusDraft,
plusStationsOf,
readPlusDraft,
savedPlusRows,
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import {
@@ -258,6 +259,7 @@ function createPagePipeline(
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
const followStage = createFollowStage({
savedLine,
savedPlus: () => savedPlusRows(projectId), // 추가 측점 정본 좌표에서 따라감(493-3)
saved: () => {
const before = beforeDraft ?? facilitiesNow();
return {
@@ -287,7 +289,11 @@ function createPagePipeline(
follow: (ctx) => {
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
replay = false;
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
// 관 · 구조물 · 추가 측점도 그 걸음 사진(사진첩이 되쓴 가게 값) — 묶음 입력에 넣어야 측점이 따라옴(280-3)
if (kept) {
const { pipes, structures, plusStations } = inputsNow();
return { alignment: kept, pipes, structures, plusStations };
}
const line = ctx.before.route?.line ?? savedLine() ?? [];
// 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410)
const frame = (list: unknown, saved: unknown) =>
@@ -9,6 +9,7 @@
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { releaseSelectAfterPick } from "@ui/ui_template_native_menu";
import type { DesignProfile } from "./design_profile";
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
@@ -101,6 +102,8 @@ function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
true,
),
);
// 새 창 드롭다운도 고른 뒤 초점 뺌 — change 는 메인으로 안 넘김(요구 280-6).
releaseSelectAfterPick(next);
// 새 창이 초점을 잃으면 길게 누르기를 끊는다(메인 창 blur 듣기).
next.addEventListener("blur", () => window.dispatchEvent(new FocusEvent("blur")));
// 새 창 크기 기준으로 다시 맞춘다.
+1 -7
View File
@@ -11,13 +11,12 @@
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { splitSidebarActions } from "@ui/ui_template_overlay";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { readState, writeState } from "../b_page_state";
import { currentRoute, navigateTo, remountRoute } from "../router";
import { currentRoute, remountRoute } from "../router";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
@@ -330,7 +329,6 @@ function routeIdOf(shared: DesignPageShared): number | null {
/** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */
export function sharedPanel(shared: DesignPageShared): DesignPanel {
if (shared.panel) return shared.panel;
const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile");
const panel = createDesignPanel(
{
projectId: shared.projectId,
@@ -345,10 +343,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
save: { run: () => saveSharedDesign(shared, false), enabled: false },
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
// [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦).
switchView: {
run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION),
},
},
},
shared.view(),
@@ -136,6 +136,14 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(projectId, routeId)
.then((result) => {
const etc = result.legacy_etc ?? [];
if (etc.length)
showToast(
`옛 측점 이관이 만든 「기타 구조물」 ${etc.length}곳(${etc.map((m) => `${m}m`).join(" · ")}) — ` +
"측점 없는 구조물입니다. 필요 없으면 구조물 목록에서 지우세요.",
"warning",
8000,
);
if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
return deps
@@ -39,7 +39,7 @@
*
* 측점 열쇠(344-3 · `stationKey` · `B05_Profile/logic/B05_Profile_Sections_Stations.ts`):
* | 측점 | 열쇠 | 뜻 |
* | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 · 스냅으로 옮긴 측점은 원래 번호 |
* | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 |
* | 관 | pipe:<id> | 관 세션 id(없으면 `pipe-{누가거리 2자리}`) |
* | 구조물 | structure:<id> | structure_id · 구간형 시작 · 끝 = `:start` · `:end` 덧붙임 |
* | 시종점 | end:BP · end:EP | 그 자리 관 · 구조물이 있어도 시종점(서버 bp · ep 와 같음) |
@@ -72,7 +72,7 @@ export interface RouteDraft {
curveArc: Array<number | null>;
/** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */
stationSnap: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */
/** 스냅 간격(m · 10 · null·없음 = 측점 No. · 280-4) — MC 측점 값 자릿수(390-5). */
stationSnapStep?: number | null;
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
terrain: string | null;
+7 -1
View File
@@ -13,7 +13,11 @@ import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
import {
blockNativeMenu,
NATIVE_MENU_ROUTE,
releaseSelectAfterPick,
} from "@ui/ui_template_native_menu";
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
@@ -58,6 +62,8 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// 드롭다운을 마우스로 고르면 초점 뺌 — 휠 · ↑↓ 가 값을 또 바꾸지 않게(요구 280-6)
releaseSelectAfterPick(window);
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
@@ -366,9 +366,10 @@ def _raw_payload(
**point.as_dict(),
"chainage_m": round(pipe.chainage_m, 2),
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
# 노선 위 자리(사업지 CRS) — 화면이 따라가기만 한 관을 가림(rebasePipeDraft · 428)
"x": round(float(pipe.x), 3),
"y": round(float(pipe.y), 3),
# 땅 위 자리(사업지 CRS · 정본 좌표 · 없으면 노선 위 자리) — 화면이 따라가기만 한 관을
# 가림(rebasePipeDraft · 428) · 노선 위 자리를 주면 [저장] 다시 얹기가 정본을 덮음(493-4)
"x": round(float(pipe.x if point.x is None else point.x), 3),
"y": round(float(pipe.y if point.y is None else point.y), 3),
"source": pipe.reason,
}
for pipe, point in zip(detail.pipes, points)
@@ -11,8 +11,11 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
import type { SavePart } from "../A00_Common/design/design_save_api";
import type { FollowingStructure } from "../common_util/common_util_route_follow";
const saved = new Map<string, number[]>();
/** 저장분 땅 위 자리(누가거리 · x · y) — 노선 초안 동안 이 좌표에서 따라감(493-3). */
const savedRows = new Map<string, FollowingStructure[]>();
const loaded = new Map<string, Promise<void>>();
async function request(projectId: string, init: RequestInit = {}): Promise<number[]> {
@@ -28,12 +31,20 @@ async function request(projectId: string, init: RequestInit = {}): Promise<numbe
const payload = await response.json().catch(() => null);
if (!response.ok)
throw new Error(payload?.message ?? `요청이 실패했습니다 (${response.status}).`);
if (Array.isArray(payload?.stations)) savedRows.set(projectId, payload.stations);
return Array.isArray(payload?.chainages) ? payload.chainages.map(Number) : [];
} finally {
window.clearTimeout(timer);
}
}
/** 저장분 추가 측점 땅 위 자리 — 좌표를 못 받았으면 누가거리만(노선 위 자리로 놓음). */
export const savedPlusRows = (projectId: string): FollowingStructure[] =>
savedRows.get(projectId) ?? (saved.get(projectId) ?? []).map((chainage_m) => ({ chainage_m }));
/** 서버 저장분을 지금 받음(초안 · 받아 둔 값 안 봄) — 추천 [적용] 확인 창 지워질 수(493-2). */
export const fetchSavedPlusStations = (projectId: string): Promise<number[]> => request(projectId);
export const readPlusDraft = (projectId: string): number[] | null =>
useDesignStore(projectId, null).get("plusStations");
@@ -138,6 +149,7 @@ export function plusStationsPart(projectId: string): SavePart {
body: draft ? { chainages: draft } : null,
after: async (response) => {
const given = response?.chainages;
if (Array.isArray(given)) savedRows.delete(projectId); // 고쳐 쓴 저장분 = 노선 위 자리(누가거리로 놓음)
const list = Array.isArray(given) ? given.map(Number) : await request(projectId);
saved.set(projectId, list);
loaded.set(projectId, Promise.resolve());
@@ -146,3 +158,12 @@ export function plusStationsPart(projectId: string): SavePart {
},
};
}
/** 저장분을 다시 받음 — 추천 [적용] 뒤(서버가 추가 측점을 지움) 옛 저장분 · 따라감 기억이 남지 않게(493-5). */
export async function reloadSavedPlusStations(projectId: string): Promise<void> {
[saved, savedRows, loaded, beforeRoute, followedOnly, warnedOutside].forEach((map) =>
map.delete(projectId),
);
await loadSavedPlusStations(projectId);
notify(projectId);
}
+4 -1
View File
@@ -19,6 +19,7 @@ import { heightAt } from "../common_util/common_util_surface_sampler";
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import type { RouteReset } from "./logic/B05_Profile_Route_Reset";
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
export type AnalysisTerrain = "las" | "sheet";
@@ -117,7 +118,7 @@ export interface RoutePlanResponse {
edited: boolean;
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
station_snap?: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
/** 스냅 간격(m · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
@@ -142,6 +143,8 @@ export interface RouteReplanResponse {
/** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */
drainage_stale?: boolean;
initial_refreshed?: boolean;
/** 노선 모양 바뀜 = 초기화(492) — 지운 사용자 구조물 · 관 · 추가 측점 수 · 옛 서버는 없음. */
route_reset?: RouteReset;
}
/** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */
+7 -1
View File
@@ -329,7 +329,13 @@ export async function saveStructures(
export async function migrateLegacyStations(
projectId: string,
routeId: number,
): Promise<{ status: string; migrated: number; revision: number }> {
): Promise<{
status: string;
migrated: number;
revision: number;
/** 옛 이관이 만든 「기타 구조물」 누가거리(지우지 않음 · 알림만 · 280-3). */
legacy_etc?: number[];
}> {
return requestJson(`/projects/${projectId}/route/structures/migrate`, {
method: "POST",
body: JSON.stringify({ route_id: routeId }),
@@ -3,8 +3,8 @@
[적용] 체인(`B05_Profile_Route_Save.run_route_save_chain`)과 같은 조각을 메모리에서 부름(파일 안
씀):
후보 노드 → 화면 `flattenServerPlan` 꼴(`draft_nodes`) → `build_planned_polyline`(편집 갈래 · csv
4자리) → 관 · 구조물 · 추가 측점 따라감(`followed_pipes` · `followed_structures` ·
`followed_plus_rows` · 새 세류선 교차 관 포함) → 측점 틀 · 1차 계획선 438(`build_sections`) →
4자리) → 초기화 시설(492 · `reset_facilities` = 새 세류선 교차 관만 · 사용자 구조물 · 추가 측점 ·
관 버림) → 측점 틀 · 1차 계획선 438(`build_sections`) →
「종단 반영」 켬이면 1 m 균형 종단을 꼭짓점에 얹음(`seat_base_pvi` → `rebuild_alignment_profile`).
[적용]은 이 값을 받아 적지 않음 — 서버가 체인에서 같은 조각으로 다시 셈(CLAUDE.md 5).
@@ -88,19 +88,12 @@ def candidate_line(
return line, [curve.as_dict() for curve in outline.curves]
def followed_facilities(
project_root: Path,
line: list[tuple[float, float]],
old_route_data_path: str | None,
old_xy: list[list[float]],
old_pipes: list,
streams: list,
) -> tuple[list, list, list[float] | None]:
"""체인과 같은 따라감(파일 안 씀) → (관 · 구조물 · 추가 측점 누가거리 | 없음 None)."""
from B05_Profile.B05_Profile_Plus_Stations import followed_plus_rows, load_plus_stations
def reset_facilities(
line: list[tuple[float, float]], streams: list
) -> tuple[list, list, list[float]]:
"""체인 초기화(492)와 같은 시설(파일 안 씀) → (관 = 새 노선 세류선 교차 기본 관만 ·
구조물 없음 · 추가 측점 없음)."""
from B05_Profile.B05_Profile_Route_Save import followed_pipes
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Router import followed_structures
from common_util.common_util_drainage_pipes import (
ensure_pipe_ids,
fill_pipe_coordinates,
@@ -108,21 +101,13 @@ def followed_facilities(
)
points = [{"x": x, "y": y} for x, y in line]
items = load_structures(str(project_root))[1]
if items:
followed_structures(project_root, items, old_route_data_path, points)
rows = load_plus_stations(project_root)
plus = (
sorted({float(r["chainage_m"]) for r in followed_plus_rows(rows, line)["rows"]})
if rows
else None
)
pairs, _crossing, vertices = followed_pipes(old_pipes, old_xy, points, streams)
pairs, _crossing, vertices = followed_pipes([], [], points, streams)
pipes = [pipe for _, pipe in pairs]
fill_pipe_coordinates(pipes, vertices)
ensure_pipe_ids(pipes)
# 체인은 관 정본을 쓰고 다시 읽음(지문 같음 = 저장분 그대로) — 같은 왕복
return parse_pipe_points([pipe.as_dict() for pipe in pipes]), items, plus
# 추가 측점 = 빈 목록(None 이면 측점 틀이 저장 파일을 읽음)
return parse_pipe_points([pipe.as_dict() for pipe in pipes]), [], []
def initial_longitudinal(
+2 -2
View File
@@ -107,10 +107,10 @@ def load_station_snap(project_root: Path) -> bool:
def load_station_snap_step(project_root: Path) -> float | None:
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
칸 없으면 None(측점 No.).
10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4).
"""
step = _read_design_curve_table(project_root).get("station_snap_step")
return float(step) if isinstance(step, int | float) and step > 0 else None
return 10.0 if isinstance(step, int | float) and step == 10 else None
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
@@ -6,10 +6,10 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ·
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `curve_stations` ·
`_mc_scale` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` ·
`snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
간격별 MC 값 `test_390_5_snap_step_ops.py`.
`curveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
`test_280_4_stations.py` · 간격별 MC 값 `test_390_5_snap_step_ops.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) ·
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
@@ -295,35 +295,6 @@ def _mc_scale(step_m: float | None) -> int:
return 10 if step_m is not None and step_m < 1 else 1
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
step_m: float | None = None,
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 MC
(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른
측점과 같은 값이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리) · `step_m` = 스냅 간격
(0.1 · 1 · 10 · None = 측점 No. · 390-5).
"""
scale = _mc_scale(step_m)
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
middle = (start + end) / 2.0
inside = [
index
for index, value in enumerate(values)
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
]
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = math.floor(middle * scale + 0.5) / scale
return np.unique(np.asarray(values, dtype=np.float64))
def curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
@@ -331,7 +302,7 @@ def curve_stations(
step_m: float | None = None,
) -> list[float]:
"""곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이
없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
없으면 그 값 · 0.005 m 안 측점(일반 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
BC · EC 는 안 더함. `curveStations` 짝."""
scale = _mc_scale(step_m)
taken = [float(value) for value in stations]
@@ -393,9 +364,8 @@ def _station_layout(
곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길."""
total = float(route_chainage[-1])
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
# 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans, station_snap_step)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
@@ -459,9 +429,9 @@ def generate_sections(
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함.
`station_snap` · `station_snap_step` — MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.) ·
일반측점은 옮기지 않음(요구 280-4). MC 에 측점이 없으면 곡선 측점(kind `curve` ·
`curve_stations` · 요구 252-2 · 「No.2+10 (MC)」) 하나를 더함.
"""
options = options or SectionGenerationOptions()
options.validate()
+2
View File
@@ -3,6 +3,8 @@
사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이
바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(TS `followPlusRows` · Node 입구).
측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝).
화면은 노선 초안 동안 이 좌표(GET `stations`)를 들고 따라감(TS `followPlusGround` · 493-3) — 거울 시험 =
`resources/tester/test_493_3_follow_screen_save.py`.
"""
from __future__ import annotations
+81 -25
View File
@@ -11,20 +11,24 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
initial_refreshed, pipes: {kept, crossing, outside}, structures: {moved, far, outside},
follow_failed, cross_dropped,
drainage_stale: true, seconds}`.
follow_failed, cross_dropped, drainage_stale: true, seconds}` · 추천 [적용](`as_initial`)만
`route_reset: {reset: true, dropped: {structures, pipes, plus_stations}}` 더함.
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
옛것으로 되살린 뒤).
규칙:
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
- 노선 초기화(`initial` = 추천 [적용] · 요구 289) = 사용자 구조물 · 추가 측점 · 관(옵션 포함)
버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · 횡단 기본 설계 · 초기값 다시
뜸(부르는 쪽).
- 손 고침 [저장](교점 · R · 스냅 · 간격 · 요구 289 · 290) = 보이는 화면 그대로 — 관 정본을
지우지 않음 · 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) ·
횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 초기화일 때만(자동설계 체인 직후 + 노선 초기화).
"""
from __future__ import annotations
@@ -199,6 +203,7 @@ async def _place_pipes(
points: list[dict[str, float]],
) -> tuple[list, dict[str, int]]:
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).
초기화(492)면 부르는 쪽이 옛 관 · 옛 선을 빈 목록으로 줌 = 새 노선 교차 관만.
따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare`
노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선
@@ -370,6 +375,36 @@ async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any])
logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id)
async def _follow_facilities(
project_id: UUID,
project_root: Path,
old_route: dict[str, Any],
points: list[dict[str, float]],
follow_failed: list[str],
) -> dict[str, int]:
"""구조물 · 추가 측점 땅 위 자리 따라감(초기화 아닌 저장) → 구조물 요약 · 실패 = 옛 누가거리."""
from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
# 구조물 = 옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움
try:
structures = await asyncio.to_thread(
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
)
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
follow_failed.append("구조물")
try: # 추가 측점(368-2)
await asyncio.to_thread(
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
)
except Exception: # noqa: BLE001
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
follow_failed.append("추가 측점")
return structures
async def run_route_save_chain(
project_id: UUID,
project_root: Path,
@@ -387,16 +422,24 @@ async def run_route_save_chain(
`planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름
(없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림).
`initial` = 추천 [적용](`as_initial`) — 옛 계획고 · 편집 · L 안 옮김 · 새 규칙 초기 종단(476).
`initial` = 노선 초기화(추천 [적용]) — 사용자 시설 버림 · 옛 계획고 ·
편집 · L 안 옮김 · 새 규칙 초기 종단(476).
"""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations, plus_stations_path
from B05_Profile.B05_Profile_Plus_Stations import (
load_plus_stations,
plus_stations_path,
save_plus_stations,
)
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route_record
from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
from B05_Profile.B05_Profile_Structures_Repository import (
load_structures,
save_structures,
structures_file_path,
)
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
@@ -425,7 +468,8 @@ async def run_route_save_chain(
old_long = _read_json(project_root / relative_old) if relative_old else {}
old_xy = _xy(project_root, old_route.get("route_data_path"))
old_pipes = read_pipe_points_file(pipe_points_path(stored_path))
_, old_structures = load_structures(str(project_root))
structures_revision, old_structures = load_structures(str(project_root))
old_plus = load_plus_stations(project_root)
stage_dir = project_root / _STAGE_SUBDIR
backup = _Backup(
project_root,
@@ -440,22 +484,16 @@ async def run_route_save_chain(
mark_designing(project_root)
try:
follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459)
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
try:
structures = await asyncio.to_thread(
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
if initial: # 초기화(492) — 사용자 구조물 · 추가 측점 버림(실패면 `backup` 이 되살림)
if old_structures:
save_structures(str(project_root), [], base_revision=structures_revision)
if old_plus:
save_plus_stations(project_root, [], None)
structures = {"moved": 0, "far": 0, "outside": 0}
else:
structures = await _follow_facilities(
project_id, project_root, old_route, points, follow_failed
)
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
follow_failed.append("구조물")
try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로)
await asyncio.to_thread(
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
)
except Exception: # noqa: BLE001
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
follow_failed.append("추가 측점")
request = redesign_solve_request(params, selection, surface_model_id)
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solved, JSONResponse):
@@ -467,7 +505,10 @@ async def run_route_save_chain(
new_xy = _xy(project_root, solved.route_data_path)
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points)
# 초기화 = 옛 관(옵션 · 사용자 관) 버림 · 새 노선 세류선 교차 관만(492)
pairs, pipes = await _place_pipes(
project_id, stored_path, [] if initial else old_pipes, [] if initial else old_xy, points
)
marks.append(("관 따라감 · 교차점", time.perf_counter()))
seed = section_seed(request, old_section_options)
@@ -540,4 +581,19 @@ async def run_route_save_chain(
"cross_dropped": dropped,
"drainage_stale": True,
"seconds": round(marks[-1][1] - marks[0][1], 2),
# 노선 초기화(추천 [적용]만 · sub1 확인 창 · 알림) — 버린 사용자 시설 수
**(
{
"route_reset": {
"reset": True,
"dropped": {
"structures": len(old_structures),
"pipes": len(old_pipes),
"plus_stations": len(old_plus),
},
}
}
if initial
else {}
),
}
+8 -4
View File
@@ -13,7 +13,8 @@
노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이
풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지
돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만.
돈다. 배수유역은 다시 안 셈(낡음) · 추천 [적용](`as_initial`)이면 초기화(사용자 시설 · 손 입력
버림 · 초기값 다시 뜸) · 손 고침 [저장]은 보이는 화면 그대로 시설 따라감(요구 289 · 290).
요청 · 응답 · 오류 모양 = 그 모듈 머리.
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
@@ -331,7 +332,7 @@ async def _recompute(
marks = [("시작", time.perf_counter())]
# 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안).
# 관 정본 = 체인이 씀 — 추천 [적용]이면 새 교차 관만 · 아니면 좌표로 새 노선을 따라감(346-3).
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음
@@ -544,7 +545,9 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
async with pool.acquire() as connection: # 설계 지형 바꿈(지표면 확정)도 떠 둠
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
files = await asyncio.to_thread(
guard.snapshot_storage, paths[0], guard.STORAGE_SCOPE + guard.PIPE_SCOPE
guard.snapshot_storage,
paths[0],
guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE,
)
try:
result = await _save_route(project_id, body)
@@ -620,7 +623,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
summary["violations"],
radius_m,
)
# 균형 종단 새로 풂 = 추천 [적용](`as_initial`)만 — 손으로 고친 노선은 계획고 옮김(344-2).
# 추천 [적용](`as_initial`) = 초기화(사용자 시설 · 손 입력 버림 · 균형 종단 새로 풂) — 손으로
# 고친 노선은 보이는 화면 그대로 시설 따라감 · 계획고 옮김(344-2 · 요구 289 · 290).
result = await _recompute(
project_id,
project_root,
@@ -41,11 +41,9 @@ async def initial_profile_of(
from B05_Profile import B05_Profile_Engine_InitialProfile as ip
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile.B05_Profile_Engine_Sections import _MODELS_SUBDIR
from B05_Profile.B05_Profile_Plus_Stations import plus_stations_path
from B05_Profile.B05_Profile_Repository import get_latest_route
from B05_Profile.B05_Profile_Route_Save import _xy, section_seed
from B05_Profile.B05_Profile_Route_Save import section_seed
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_profile_of, project_standard
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_route_generation_source,
@@ -54,7 +52,6 @@ async def initial_profile_of(
_regeneration_grade_options,
_regeneration_options,
)
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
from common_util.common_util_surface_sampler import build_surface_sampler
from common_util.common_util_workflow_state import get_workflow_state
@@ -101,14 +98,8 @@ async def initial_profile_of(
"surface": surface,
"models": ip.stat_marks(list(models.glob(f"*_{surface['source_filter']}*.npz"))),
"streams": ip.stat_marks([_sheet_dir(stored_path) / STREAM_FILE]),
"files": ip.file_marks(
[
pipe_points_path(stored_path),
Path(structures_file_path(str(project_root))),
plus_stations_path(project_root),
project_root / route_path,
]
),
# 초기화(492) = 저장 관 · 구조물 · 추가 측점을 안 씀 — 노선 파일만
"files": ip.file_marks([project_root / route_path]),
"options": asdict(options),
"grade": grade_options.as_dict() if grade_options else None,
"crs": crs,
@@ -123,15 +114,7 @@ async def initial_profile_of(
return stored
prepared = await _prepare(project_id)
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"]
facilities = await asyncio.to_thread(
ip.followed_facilities,
project_root,
line,
route_path or None,
_xy(project_root, route_path),
read_pipe_points_file(pipe_points_path(stored_path)),
streams,
)
facilities = await asyncio.to_thread(ip.reset_facilities, line, streams)
target = None
if request.with_profile: # [적용] `balanced_target` 과 같은 셈(노선 정점 · 새 곡선표)
points = [{"x": x, "y": y} for x, y in line]
+1 -1
View File
@@ -433,4 +433,4 @@ class RouteReplanRequest(BaseModel):
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
station_snap_step: float | None = None
"""스냅 간격(m · 0.1 · 1 · 10 · None = 측점 No. · 390-5) — 곡선표 `"station_snap_step"`."""
"""스냅 간격(m · 10 · None = 측점 No. · 390-5 · 280-4) — 곡선표 `"station_snap_step"`."""
@@ -65,7 +65,9 @@ def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bo
label = str(entry.get("structure", "")).strip()
at = float(entry.get("chainage_m", 0.0))
return (
entry.get("origin") == "pipe"
# 이름 빈 측점 = 추가 측점 정본(`plus_stations.json`) 소관 — 「기타」로 안 옮김(280-3)
not label
or entry.get("origin") == "pipe"
or entry.get("structureType") == PIPE_STRUCTURE_NAME
or label == PIPE_STRUCTURE_NAME
or label in {item.name for item in structure_type_map().values()}
+53 -11
View File
@@ -49,7 +49,7 @@ from common_util.common_util_route_follow import (
route_outside,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_station import plan_stations
from common_util.common_util_structure_station import SAME_STATION_M, plan_stations
from config.config_db import get_db_pool
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
@@ -266,9 +266,10 @@ class StructureMigrateRequest(BaseModel):
async def _saved_irregular_stations(
project_id: UUID, root: str, route_id: int | None
) -> list[dict[str, Any]]:
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안
(옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
) -> tuple[list[dict[str, Any]], list[float]]:
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천 · 서 있는 구조물 측점
누가거리(비정규 · 이름 단 격자 · 낡음 판정 280-3). 화면 상세는 초안(옮긴 관 자리)이 섞일 수
있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path
@@ -283,21 +284,37 @@ async def _saved_irregular_stations(
else None
)
if not row or not row.get("longitudinal_file_path"):
return []
return [], []
try:
path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"]))
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return []
return [
return [], []
stations = [station for station in stations if station.get("chainage_m") is not None]
legacy = [
{"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""}
for station in stations
if station.get("kind") == "irregular" and station.get("chainage_m") is not None
if station.get("kind") == "irregular"
]
standing = [
float(station["chainage_m"])
for station in stations
if station.get("kind") == "irregular" or station.get("structure")
]
return legacy, standing
@router.post("/{project_id}/route/structures/migrate", response_model=None)
async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
"""구 비정규 측점 이관 — [저장]과 같은 프로젝트 잠금 안(280-3 · 다른 탭 저장 도중 낡은 종단으로
옮기지 않음 · 몸통 `_migrate_structures`)."""
from common_util import common_util_save_guard as guard
async with guard.project_lock(str(project_id)):
return await _migrate_structures(project_id, payload)
async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
"""구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는
(타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다
(2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의)."""
@@ -320,15 +337,38 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
plus_chainages(root),
)
]
legacy = await _saved_irregular_stations(project_id, root, payload.route_id)
plan = migrate_irregular_stations(legacy, planted)
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
def key_of(item: StructureInstance) -> str:
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
# 옛 이관이 만든 「기타 구조물」(이름 없는 측점 · 메모 없음) — 지우지 않고 알림만(280-3)
legacy_etc = [
round(item.anchor_m(), 3)
for item in existing
if item.type_id == "etc"
and key_of(item) in migrated_before
and str(item.options.get("name") or "기타 구조물") == "기타 구조물"
and not item.memo
]
# 종단 정본이 지금 심는 자리를 다 못 세움 = 낡음(저장 도중 끊김 · 다른 탭 [저장] 도중) —
# 낡은 측점을 「기타」로 옮기지 않음(280-3)
if any(all(abs(at - s) >= SAME_STATION_M for s in standing) for at in planted):
return JSONResponse(
content={
"status": "success",
"migrated": 0,
"revision": revision,
"pipe_facilities": 0,
"skipped": "stale_longitudinal",
"legacy_etc": legacy_etc,
}
)
plan = migrate_irregular_stations(legacy, planted)
occupied = {key_of(item) for item in existing}
fresh = [
item
@@ -360,6 +400,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
"migrated": moved_pipes,
"revision": revision,
"pipe_facilities": moved_pipes,
"legacy_etc": legacy_etc,
}
)
new_revision = save_structures(
@@ -381,6 +422,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
"migrated": len(fresh) + moved_pipes,
"revision": new_revision,
"pipe_facilities": moved_pipes,
"legacy_etc": legacy_etc,
}
)
except LookupError:
@@ -50,3 +50,54 @@
justify-content: flex-end;
gap: var(--spacing-8, 8px);
}
/* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */
.b05-crosshint {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 4;
width: 0;
pointer-events: none;
}
.b05-crosshint[hidden],
.b05-crosshint__dot[hidden] {
display: none;
}
.b05-crosshint__line {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-left: 1px dashed var(--color-accent);
}
.b05-crosshint__dot {
position: absolute;
top: var(--crosshint-y);
left: 0;
width: 10px;
height: 10px;
border: 2px solid var(--color-accent);
border-radius: 50%;
background: var(--color-surface);
transform: translate(-50%, -50%);
box-sizing: border-box;
}
.b05-crosshint__label {
position: absolute;
top: var(--crosshint-y);
left: 10px;
transform: translateY(-50%);
padding: 1px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
white-space: nowrap;
}
@@ -7,7 +7,7 @@
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
* 측점일 때만 · [닫기].
* 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1).
* ========================================================================== */
import "./B05_Profile_UI_Profile_CrossModal.css";
@@ -30,9 +30,13 @@ import {
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import {
crossPickAt,
groundElevationAt,
type CrossPickHit,
type GroundSample,
} from "./logic/B05_Profile_Profile_CrossPick";
/** 원지반선을 눌렀다고 볼 위아래 여유(px). */
const GROUND_SLACK_PX = 8;
/** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */
const SAME_M = 0.5;
@@ -43,42 +47,87 @@ export interface CrossModalOpen {
intervalM: number;
}
/** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */
export function groundPickAt(
/** 그래프 위 포인터 → 임시 횡단 자리(정수 m · 그 자리 원지반선 y px) · 아니면 null.
* 누름(작은 창) · 가까이 표시가 같이 씀(490-1) — 판정은 `crossPickAt` 한 벌. */
export function crossPickFromEvent(
chart: HTMLElement,
event: MouseEvent,
chainageAtPx: (px: number) => number,
samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>,
): number | null {
samples: readonly GroundSample[],
): { chainageM: number; groundY: number | null } | null {
const target = event.target as Element;
const marker = target.closest(".b06-chart__station");
const plusRaw = marker?.querySelector(".b06-chart__station-line--plus")
? Number(marker.getAttribute("data-chainage"))
: null;
const hit: CrossPickHit =
plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null;
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
if (!svg) return null;
const read = (name: string): number => Number(svg.getAttribute(name));
const read = (name: string): number => Number(svg?.getAttribute(name));
const view: ChartWindow = {
center: read(Y_WINDOW_ATTRS.center),
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
zero: read(Y_WINDOW_ATTRS.zero),
...parseWindowTransform(
svg.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null,
svg?.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null,
),
};
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0)
return null;
const rect = chart.getBoundingClientRect();
const chainage = chainageAtPx(event.clientX - rect.left);
const valid = samples.filter(
(s): s is { chainage_m: number; elevation_m: number } =>
Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN),
);
const after = valid.findIndex((s) => s.chainage_m >= chainage);
if (!Number.isFinite(chainage) || after <= 0) return null;
const [a, b] = [valid[after - 1], valid[after]];
const t = (chainage - a.chainage_m) / (b.chainage_m - a.chainage_m || 1);
const ground = a.elevation_m + (b.elevation_m - a.elevation_m) * t;
const top = svg.getBoundingClientRect().top - rect.top;
const groundY = chartYAtElevation(view, ground) + top;
return Math.abs(event.clientY - rect.top - groundY) <= GROUND_SLACK_PX
? Math.round(chainage)
: null;
const top = svg ? svg.getBoundingClientRect().top - rect.top : 0;
const viewOk =
!!svg && [view.center, view.pxPerM, view.zero].every(Number.isFinite) && view.pxPerM > 0;
const groundY = (elevation: number): number =>
viewOk ? chartYAtElevation(view, elevation) + top : NaN;
// 포인터 이동은 소수 px · 누름은 정수 px — 내려 맞춰 같은 자리 = 같은 m(0.5 m 경계에서 1 m 어긋남 막음).
const chainageM = crossPickAt({
hit,
chainageM: chainageAtPx(Math.floor(event.clientX) - rect.left),
pointerY: Math.floor(event.clientY) - rect.top,
samples,
groundY,
});
if (chainageM === null || !Number.isFinite(chainageM)) return null;
const ground = groundElevationAt(samples, chainageM);
const y = ground === null ? NaN : groundY(ground);
return { chainageM, groundY: Number.isFinite(y) ? y : null };
}
/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1).
* 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
export function mountCrossHint(
chart: HTMLElement,
scroller: HTMLElement,
options: {
pick: (event: MouseEvent) => { chainageM: number; groundY: number | null } | null;
x: (chainageM: number) => number;
intervalM: number;
blocked: () => boolean;
},
): void {
const hint = document.createElement("div");
hint.className = "b05-crosshint";
hint.hidden = true;
hint.innerHTML = `<i class="b05-crosshint__line"></i><i class="b05-crosshint__dot"></i><span class="b05-crosshint__label"></span>`;
const dot = hint.querySelector<HTMLElement>(".b05-crosshint__dot")!;
const label = hint.querySelector<HTMLElement>(".b05-crosshint__label")!;
chart.append(hint);
const hide = (): void => {
hint.hidden = true;
chart.style.cursor = "";
};
chart.addEventListener("pointermove", (event) => {
const at = event.buttons || options.blocked() ? null : options.pick(event);
if (!at) return hide();
hint.hidden = false;
hint.style.left = `${options.x(at.chainageM)}px`;
dot.hidden = at.groundY === null;
// 원 · 글 높이 = 원지반선(없으면 위쪽) — 글이 위 ▲ 단추를 가리지 않게 원 옆에 둠.
hint.style.setProperty("--crosshint-y", `${at.groundY ?? 12}px`);
label.textContent = `${stationLabel(at.chainageM, options.intervalM)} · 눌러 임시 횡단`;
chart.style.cursor = "pointer";
scroller.addEventListener("scroll", hide, { once: true, passive: true });
});
chart.addEventListener("pointerleave", hide);
}
interface CrossModal {
+11 -11
View File
@@ -53,7 +53,8 @@ import {
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import {
groundPickAt,
crossPickFromEvent,
mountCrossHint,
openCrossModal,
removePlusStation,
} from "./B05_Profile_UI_Profile_CrossModal";
@@ -328,19 +329,11 @@ export function renderProfile(ctx: ProfileRenderContext): void {
}
// 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3)
// 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼)
const target = event.target as Element;
const marker = target.closest(".b06-chart__station");
const plusAt = marker?.querySelector(".b06-chart__station-line--plus")
? marker.getAttribute("data-chainage")
: null;
if ((marker && !plusAt) || target.closest("button")) return;
const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset);
const at = plusAt
? Math.round(Number(plusAt))
: groundPickAt(chartWrap, event, toChainageAt, graphData.samples);
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
if (at === null) return;
openCrossModal(body.parentElement ?? body, {
chainageM: at,
chainageM: at.chainageM,
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
intervalM: stationIntervalM ?? 20,
});
@@ -495,6 +488,13 @@ export function renderProfile(ctx: ProfileRenderContext): void {
}),
);
}
// 임시 횡단이 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(490-1 · 누름과 같은 판정).
mountCrossHint(chartWrap, body, {
pick: (event) => crossPickFromEvent(chartWrap, event, toChainage, graphData.samples),
x,
intervalM: stationIntervalM ?? 20,
blocked: ctx.toolActive,
});
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
// 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6).
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
+4 -5
View File
@@ -436,8 +436,7 @@ export async function mountRouteEditor(
markEdited();
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
if (stop && before) return restoreSnapshot(before, stop, false);
const from = fit.fitStart(before, snapshotNow());
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits());
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
@@ -457,7 +456,7 @@ export async function mountRouteEditor(
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
function fitLimits(): fit.FitLimits {
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap(), stationM: stationIntervalM };
}
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
@@ -542,8 +541,8 @@ export async function mountRouteEditor(
},
snapStepM: snap,
fitDrag: (index) => {
const base = stationSnap ? history?.current?.()?.planned : undefined;
if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits());
const base = stationSnap ? history?.current?.() : undefined; // 누르기 전 노선 — 280-4 유지
if (base) planned = fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base);
if (base) markEdited();
return base !== undefined;
},
@@ -30,12 +30,14 @@ import {
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import type { SavePart } from "../A00_Common/design/design_save_api";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import { reloadSavedPlusStations } from "./B05_Profile_Api_PlusStations";
import {
replanRoute,
type RouteReplanResponse,
type RouteSaveBody,
} from "./B05_Profile_Api_Replan";
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
import { routeResetNotice } from "./logic/B05_Profile_Route_Reset";
type Vertex = [number, number];
type RouteSettings = SnapSetting & { terrain: string | null };
@@ -207,7 +209,7 @@ export async function saveRecommendedRoute(
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
showLoadingOverlay();
try {
await saveRouteDraft(projectId, {
const saved = await saveRouteDraft(projectId, {
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
initial,
@@ -215,8 +217,10 @@ export async function saveRecommendedRoute(
as_initial: true,
});
clearDrafts(projectId);
await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5)
sources.get(projectId)?.saved();
showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success");
const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0);
showToast(`추천 노선을 저장했습니다 — 새 초기값입니다.${reset ? ` ${reset}` : ""}`, "success");
await rebuildAfterRouteSave(projectId);
return true;
} catch (error) {
@@ -110,12 +110,10 @@ export function createRouteEditChrome(
<i class="b05-route__view-separator" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
aria-pressed="false"
title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
title="켜면 고친 곡선의 가운데(MC)를 고른 간격 배수에 맞춥니다 — 일반측점(20 m)은 그대로 · MC 가 일반측점 위가 아니면 「No.N+10 (MC)」 특별측점을 더함 · 손대지 않은 곡선은 그 자리 유지 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
<label class="b05-routeedit__terrain b05-routeedit__snap-step"
title="측점 스냅 간격 — 곡선 가운데(MC)를 이 간격 배수에 맞춥니다(측점 No. = 측점 간격)">간격
<select class="ui-select ui-select--sm" data-act="snap-step">
<option value="0.1">0.1 m</option>
<option value="1">1 m</option>
<option value="10">10 m</option>
<option value="station">측점 No.</option>
</select>
@@ -42,6 +42,7 @@ import {
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply";
import { applyResetText } from "./B05_Profile_UI_RouteReset";
export interface InitialPanelParams {
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
@@ -158,6 +159,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
current: params.planned().map(([x, y]) => [x, y]),
request,
progress: () => fetchPrecomputeProgress(projectId),
confirmText: () => applyResetText(projectId),
onApply: (picked) => {
choice = picked;
writeInitialChoice(projectId, choice);
@@ -88,9 +88,6 @@ const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
};
/** [적용] 확인 글(476 · 요구 277) — 같은 노선이어도 수작업(종단 · 횡단 손 입력)을 모두 버린다. */
const APPLY_CONFIRM =
"이 노선과 미리 본 초기 종단을 저장합니다 — 종단 · 횡단 손 입력이 모두 지워지고 이 노선 기준으로 새로 시작합니다. 적용할까요?";
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
@@ -154,6 +151,8 @@ export interface RecommendParams {
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
/** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */
confirmText: () => Promise<string>;
/** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */
progress?: () => Promise<PrecomputeProgress | null>;
}
@@ -622,7 +621,7 @@ export function openRecommendModal(params: RecommendParams): void {
applyButton.addEventListener("click", () => {
const picked = preview;
if (!picked) return;
void showConfirmDialog(APPLY_CONFIRM, "적용").then((ok) => {
void (async () => showConfirmDialog(await params.confirmText(), "적용"))().then((ok) => {
if (!ok || closed || preview !== picked) return;
params.onApply(picked);
closed = true;
@@ -2,27 +2,23 @@
* B05_Profile_UI_RouteEdit_SnapStep.ts
* 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11).
*
* 간격 = 0.1 m · 1 m · 10 m · 측점 No.(편집 창 측점 간격 · 기본) — 곡선 MC 를 그 간격 배수에 맞춤.
* 간격 = 10 m · 측점 No.(편집 창 측점 간격 · 기본 · 280-4) — 곡선 MC 를 그 간격 배수에 맞춤.
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · 스냅 켬이면 저장 노선에도 같이
* (`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.).
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
* 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 10 m = 「10 m 배수에」.
* 평면 눈금 = 일반측점(스냅이 옮기지 않음 · 280-4) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
curveSpans,
curveStations,
snapCurveStations,
stationsAlong,
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
import { curveSpans, curveStations, stationsAlong } from "./logic/B05_Profile_UI_RouteEdit_Frame";
const STEPS = ["0.1", "1", "10", "station"];
/** 고르기 값 — 옛 "0.1" · "1"(pref · 저장)은 측점 No. 로 읽음(280-4). */
const STEPS = ["10", "station"];
/** 저장 · 초안에 실리는 스냅 두 칸. */
export type SnapSetting = Pick<RouteDraft, "stationSnap" | "stationSnapStep">;
@@ -35,7 +31,7 @@ export function paintStationSnap(button: HTMLButtonElement, on: boolean): void {
button.setAttribute("aria-pressed", String(on));
}
/** 스냅 뒤 상태줄 글 — `step` = 고르기 값("0.1" · "1" · "10" · "station"). */
/** 스냅 뒤 상태줄 글 — `step` = 고르기 값("10" · "station"). */
export function snapFitText(step: string): string {
const where = step === "station" ? "일반측점" : `${step} m 배수`;
return `곡선 가운데(MC)를 ${where}에 맞췄습니다.`;
@@ -52,8 +48,8 @@ export type SnapStep = {
load: (plan: SavedSnap) => boolean;
/** 읽은 저장 노선과 켬끔 · 간격이 다른가([확인] 보낼지). */
changed: () => boolean;
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
/** 편집 창 일반측점 — 끔이면 null · 켬이면 측점 간격 그대로(옮기지 않음 · 280-4). */
stations: (line: ReadonlyArray<Vertex>, curves?: ReadonlyArray<EditedCurve>) => number[] | null;
/** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */
ticks: (
line: ReadonlyArray<Vertex>,
@@ -80,14 +76,8 @@ export function bindStationSnap(
const step = (): number | null => (select.value === "station" ? null : Number(select.value));
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
const stations = (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
on()
? snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curves),
step(),
)
: null;
const stations = (line: ReadonlyArray<Vertex>) =>
on() ? stationsAlong(line, stationIntervalM, false) : null;
const stepM = () => step() ?? stationIntervalM;
return Object.assign(stepM, {
text: () => snapFitText(select.value),
@@ -107,11 +97,11 @@ export function bindStationSnap(
},
stations,
ticks: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => {
const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false);
const base = stationsAlong(line, stationIntervalM, false);
// 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안
const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null);
return {
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves),
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line),
curveStations: curve,
};
},
+33
View File
@@ -0,0 +1,33 @@
/* =============================================================================
* B05_Profile_UI_RouteReset.ts
* 노선 초기화 = 추천 [적용](요구 289 · 493-2) — 확인 창 글 몫.
*
* 지워질 수 = 서버 저장분(구조물 · 관 · 추가 측점) — 서버가 [적용]에서 지우는 것과 같음(응답
* `route_reset.dropped`) · 세션 초안은 안 셈 · 관 = 저장 파일이 없으면 0(응답 관은 자동 제안).
* 구조물 · 관 = 페이지 자료 줄(저장 · 적용 뒤 다시 받음) · 추가 측점 = 지금 받음.
* 손 고침 [저장]은 묻지 않음(보이는 화면 그대로 저장).
* 글 = `logic/B05_Profile_Route_Reset`.
* ========================================================================== */
import { pagePipes, pageStructures } from "../A00_Common/design/design_page_data";
import { fetchSavedPlusStations } from "./B05_Profile_Api_PlusStations";
import { routeResetConfirmText } from "./logic/B05_Profile_Route_Reset";
/** 추천 [적용] 확인 창 글 — 서버 저장분에서 지워질 수(못 받은 칸 = 0). */
export async function applyResetText(projectId: string): Promise<string> {
const [structures, pipes, plusStations] = await Promise.all([
pageStructures(projectId).then(
(list) => list.structures?.length ?? 0,
() => 0,
),
pagePipes(projectId).then(
(list) => (list.saved ? (list.pipe_points?.length ?? 0) : 0),
() => 0,
),
fetchSavedPlusStations(projectId).then(
(list) => list.length,
() => 0,
),
]);
return routeResetConfirmText({ structures, pipes, plusStations });
}
@@ -5,7 +5,7 @@
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
* 추가 측점 좌표 따라감(459 · `followPlusStations`).
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
@@ -25,7 +25,7 @@ import type {
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
import {
followPlusStations,
followPlusGround,
followStructures,
placeOnLine,
projectPipePoints,
@@ -112,6 +112,8 @@ interface GradeRef {
export function createFollowStage(deps: {
savedLine(): Vertex[] | null;
saved?(): FollowPatch;
/** 저장분 추가 측점 땅 위 자리(누가거리 · x · y) — 첫 걸음이 서버 정본 좌표에서 따라감(493-3). */
savedPlus?(): ReadonlyArray<FollowingStructure>;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
@@ -134,9 +136,19 @@ export function createFollowStage(deps: {
undefined,
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
).map((moved) => moved.item);
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀
if (plusStations?.length)
patch.plusStations = followPlusStations(plusStations, xy(before), line);
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀 · 좌표 =
// 앞 걸음 따라감 · 없으면 저장분(493-3 · 걸음마다 정수 m 에서 다시 놓으면 밀림)
if (plusStations?.length) {
const saved = deps.savedPlus?.() ?? [];
// 초안 둔 채 열기(앞 노선 없음)의 초안 목록 = 이미 초안 노선 자리(관 · 구조물 `frame` 과 같은 셈)
const onDraft =
!ctx.before.route &&
JSON.stringify(plusStations) !== JSON.stringify(saved.map((row) => row.chainage_m));
const ground = onDraft ? [] : ((ctx.before.follow && plusAt.get(ctx.before.follow)) ?? saved);
const rows = followPlusGround(plusStations, onDraft ? line : xy(before), line, ground);
patch.plusStations = rows.map((row) => row.chainage_m!);
plusAt.set(patch, rows);
}
// 노선 초안 전 관 · 구조물이 따라가기만 한 자리 — planline 이 사용자가 옮긴 것을 가름(459 · 427)
const origin = deps.saved?.();
const home = deps.savedLine();
@@ -162,6 +174,9 @@ export function createFollowStage(deps: {
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */
const SEAT_SAME_M = 0.006;
/** follow 출력마다 추가 측점 땅 위 자리(누가거리 · x · y) — 다음 걸음이 이 좌표에서 따라감(493-3). */
const plusAt = new WeakMap<object, FollowingStructure[]>();
/** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */
const followedAt = new WeakMap<object, ReadonlyMap<string, number[]>>();
@@ -0,0 +1,53 @@
/* =============================================================================
* logic/B05_Profile_Profile_CrossPick.ts
* 종단 그래프 한 점 → 임시 횡단을 열 누가거리(정수 m) · 아니면 null(368-3 · 490-1).
*
* 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 다른 측점선 · 단추 =
* 그 몫(null) · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안.
* ========================================================================== */
/** 원지반선을 눌렀다고 볼 위아래 여유(px). */
export const GROUND_SLACK_PX = 8;
/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */
export type CrossPickHit = { plusM: number } | "other" | null;
export type GroundSample = { chainage_m?: number; elevation_m?: number | null };
export interface CrossPickInput {
hit: CrossPickHit;
/** 포인터 x 의 누가거리. */
chainageM: number;
/** 포인터 y(그래프 px). */
pointerY: number;
samples: readonly GroundSample[];
/** 표고 → 그래프 y px(세로 창 없으면 NaN). */
groundY: (elevationM: number) => number;
}
/** 누가거리의 원지반 표고(앞뒤 표본 직선 보간) · 첫 표본 앞 · 끝 뒤 · 빈 표본 = null. */
export function groundElevationAt(
samples: readonly GroundSample[],
chainageM: number,
): number | null {
const valid = samples.filter(
(s): s is { chainage_m: number; elevation_m: number } =>
Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN),
);
const after = valid.findIndex((s) => s.chainage_m >= chainageM);
if (!Number.isFinite(chainageM) || after <= 0) return null;
const [a, b] = [valid[after - 1], valid[after]];
const t = (chainageM - a.chainage_m) / (b.chainage_m - a.chainage_m || 1);
return a.elevation_m + (b.elevation_m - a.elevation_m) * t;
}
/** 임시 횡단을 열 누가거리(정수 m) · 아니면 null. */
export function crossPickAt(input: CrossPickInput): number | null {
if (input.hit === "other") return null;
if (input.hit) return Math.round(input.hit.plusM);
const ground = groundElevationAt(input.samples, input.chainageM);
if (ground === null) return null;
return Math.abs(input.pointerY - input.groundY(ground)) <= GROUND_SLACK_PX
? Math.round(input.chainageM)
: null;
}
@@ -3,7 +3,7 @@
* 교각점 방향키(383-2 · 요구 241-16) — 화면 축 걸음 · 뗄 때 맞춘 결과 한 줄.
*
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* · 측점 스냅 켬이면 한 번 = 스냅 간격(10 m · 측점 No. · Shift 안 씀 · 390-5 · 280-4) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
@@ -7,7 +7,10 @@
* 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454).
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
* · 간격 = 스냅 간격(390-2 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
* · 유지(280-4) — 고치기 전 노선(`before`)에서 MC 가 측점 · 10 m 배수(No.N+10)였던 곡선은 그 IP · 곡선 칸이
* 그대로면 옛 꼴(측점 간격 배수 + 0 · 10)로만 맞춤(밀 거리 = 측점 간격) · 손으로 고친 곡선만 고른 간격.
* MC 가 고른 간격 배수 · 10 m 배수면 「위」(No.N+10 특별측점 · 저장 칸 없음 = 노선 모양으로 기억).
* · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로
* 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값.
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
@@ -29,14 +32,21 @@ export const MC_TOL_M = 0.05;
/** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */
export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20);
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
/** 특별측점 간격(m) — MC 가 일반측점 사이 10 m 배수(No.N+10)면 측점 위로 봄(280-4). */
export const SPECIAL_STATION_M = 10;
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격(스냅) · 측점 간격(없으면 스냅 간격). */
export interface FitLimits {
minRadiusM: number;
limitRadiusM: number;
limitArcM: number;
intervalM: number;
stationM?: number;
}
/** MC 를 놓을 격자 — 간격 · 어긋남(N+10 꼴 = 10) · 밀 거리. */
type Grid = { step: number; offset: number; reach: number };
type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curveArc" | "apexLock">;
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
@@ -60,24 +70,56 @@ export function offStationCount(
return offStationMcs(line, curves, intervalM).length;
}
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정 ·
* 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」. */
export function offStationMcs(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number[] {
if (!(intervalM > 0)) return [];
const tol = mcTolM(intervalM);
const hits = new Set<number>();
const off: number[] = [];
for (const [start, end] of curveSpans(line, curves)) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
const at = [intervalM, SPECIAL_STATION_M]
.map((step) => Math.round(mc / step) * step)
.find((c) => c > tol && Math.abs(mc - c) <= tol && !hits.has(Math.round(c * 1000)));
if (at !== undefined) hits.add(Math.round(at * 1000));
else off.push(mc);
}
return off;
}
/** 꺾임점 `k` 의 IP 자리 + 곡선 칸 — 고치기 전 · 뒤에 같으면 손대지 않은 곡선. */
const nodeKey = (cols: Columns & Pick<NodeColumns, "planned">, k: number): string =>
JSON.stringify([
cols.planned[k],
cols.curveOn[k],
cols.curveRadius[k],
cols.curveLock[k],
cols.curveArc[k],
]);
/** 고치기 전 노선의 곡선 꼴 — MC 가 10 m 배수인 곡선의 `nodeKey` → 측점 간격 안 어긋남(0 · 10). */
function keptForms(
before: Columns & Pick<NodeColumns, "planned">,
limits: FitLimits,
stationM: number,
): Map<string, number> {
const built = build(before, before.planned, limits);
const out = new Map<string, number>();
for (let k = 1; k < before.planned.length - 1; k += 1) {
const mc = mcOf(built, k);
if (mc === null) continue;
const at = Math.round(mc / SPECIAL_STATION_M) * SPECIAL_STATION_M;
if (at <= 0 || Math.abs(mc - at) > mcTolM(SPECIAL_STATION_M)) continue;
out.set(nodeKey(before, k), ((at % stationM) + stationM) % stationM);
}
return out;
}
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
export function fitStart(
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
@@ -98,7 +140,8 @@ export function fitStart(
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 ±reach 안 d 밀어 MC = target(할선법 · 기울기 ≈ 1) ·
* reach = 간격(한가운데 18+9 ~ 19+1 도 가까운 측점까지 · 441 · 반 간격이면 못 닿음). */
function solve(
cols: Columns,
limits: FitLimits,
@@ -106,6 +149,7 @@ function solve(
k: number,
target: number,
mc: number,
reach: number,
): { planned: Vertex[]; built: EditedPolyline; mc: number } | null {
const base = planned[k];
const before = planned[k - 1];
@@ -113,7 +157,6 @@ function solve(
if (!(length > 1e-9)) return null;
const ux = (base[0] - before[0]) / length;
const uy = (base[1] - before[1]) / length;
const reach = limits.intervalM; // 한가운데(18+9 ~ 19+1)도 가까운 측점까지(441 · 반 간격이면 못 닿음)
const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
const at = (d: number) => {
const next = planned.slice();
@@ -141,11 +184,13 @@ function solve(
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로).
* 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②)
* — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */
* — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐.
* `before` = 고치기 전 노선 — 손대지 않은 곡선은 그 MC 꼴(N+0 · N+10) 유지(280-4). */
export function fitCurveStations(
cols: Columns & Pick<NodeColumns, "planned">,
limits: FitLimits,
from: number,
before?: (Columns & Pick<NodeColumns, "planned">) | null,
): Vertex[] {
let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
const interval = limits.intervalM;
@@ -153,19 +198,33 @@ export function fitCurveStations(
let built = build(cols, planned, limits);
const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
const tol = mcTolM(interval);
type Fit = { planned: Vertex[]; built: EditedPolyline };
/** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */
const near = (mc: number, last: number): number[] => {
const lo = Math.floor(mc / interval);
return [lo, lo + 1]
.filter((n) => n > last)
.sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc));
const stationM = limits.stationM ?? interval;
const forms = before ? keptForms(before, limits, stationM) : new Map<string, number>();
const gridOf = (k: number): Grid => {
const offset = forms.get(nodeKey(cols, k));
return offset === undefined
? { step: interval, offset: 0, reach: interval }
: { step: stationM, offset, reach: stationM };
};
const onAt = (mc: number, last: number) =>
near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol);
/** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */
const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => {
const fitted = solve(cols, limits, at.planned, k, n * interval, mc);
type Fit = { planned: Vertex[]; built: EditedPolyline };
/** 곡선을 놓을 측점(누가거리) — 내림 · 올림 중 앞 측점 뒤 · 가까운 차례. */
const near = (mc: number, last: number, grid: Grid): number[] => {
const lo = Math.floor((mc - grid.offset) / grid.step);
return [lo, lo + 1]
.map((n) => n * grid.step + grid.offset)
.filter((c) => c > last + tol)
.sort((a, b) => Math.abs(a - mc) - Math.abs(b - mc));
};
/** 이미 측점 위 — 그 곡선 격자 · 고른 간격 · 10 m 배수(N+10) 중 하나. */
const loose: Grid[] = [
{ step: interval, offset: 0, reach: 0 },
{ step: SPECIAL_STATION_M, offset: 0, reach: 0 },
];
const onAt = (mc: number, last: number, grid: Grid) =>
[grid, ...loose].flatMap((g) => near(mc, last, g)).find((c) => Math.abs(mc - c) <= tol);
/** 곡선 `k` 를 측점 `c` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */
const attempt = (at: Fit, k: number, mc: number, c: number, previous: number): Fit | null => {
const fitted = solve(cols, limits, at.planned, k, c, mc, gridOf(k).reach);
if (
!fitted ||
shortfallCrossed(
@@ -185,46 +244,60 @@ export function fitCurveStations(
for (let j = k + 1; j < at.planned.length - 1; j += 1) {
const mc = mcOf(at.built, j);
if (mc === null) continue;
if (onAt(mc, last) !== undefined) return true;
return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null);
if (onAt(mc, last, gridOf(j)) !== undefined) return true;
const grid = gridOf(j);
return (
!cols.apexLock[j] && near(mc, last, grid).some((c) => attempt(at, j, mc, c, k) !== null)
);
}
return true;
};
let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
let lastStation = 0; // 앞 곡선이 놓인 측점 누가거리(없으면 0 = 시점 · 서로 다른 측점)
let previous = -1; // 앞 곡선 꺾임점
for (let k = 1; k < planned.length - 1; k += 1) {
const mc = mcOf(built, k);
if (mc === null) continue;
const on = onAt(mc, lastStation);
const on = onAt(mc, lastStation, gridOf(k));
if (on !== undefined) lastStation = on;
else if (k >= from && !cols.apexLock[k]) {
let pick: { fit: Fit; n: number } | null = null;
for (const n of near(mc, lastStation)) {
const fit = attempt({ planned, built }, k, mc, n, previous);
let pick: { fit: Fit; c: number } | null = null;
for (const c of near(mc, lastStation, gridOf(k))) {
const fit = attempt({ planned, built }, k, mc, c, previous);
if (!fit) continue;
pick ??= { fit, n };
if (nextSettles(fit, k, n)) {
pick = { fit, n };
pick ??= { fit, c };
if (nextSettles(fit, k, c)) {
pick = { fit, c };
break;
}
}
if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n));
if (pick) (({ planned, built } = pick.fit), (lastStation = pick.c));
}
previous = k;
}
return planned;
}
/** 편집 한 걸음 뒤 맞춤 — 고친 자리(`fitStart`)부터 · 손대지 않은 곡선은 `before` 꼴 유지(280-4). */
export function fitAfter(
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
now: Columns & Pick<NodeColumns, "planned">,
limits: FitLimits,
): Vertex[] {
return fitCurveStations(now, limits, fitStart(before, now), before);
}
/** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작) · `before` = 누르기 전 노선(없으면 `base` + 지금 칸 · 280-4 유지). */
export function fitDrag(
cols: Columns & Pick<NodeColumns, "planned">,
base: ReadonlyArray<Vertex>,
k: number,
limits: FitLimits,
before?: (Columns & Pick<NodeColumns, "planned">) | null,
): Vertex[] {
if (base.length !== cols.planned.length)
return fitCurveStations(cols, limits, Math.max(1, k - 1));
return fitCurveStations(cols, limits, Math.max(1, k - 1), before);
const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex));
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1));
const kept = before ?? { ...cols, planned: base.map((v): Vertex => [v[0], v[1]]) };
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1), kept);
}
@@ -0,0 +1,42 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Route_Reset.ts
* 노선 초기화 = 추천 [적용](요구 289 · 493-2) — 확인 창 글 · 적용 뒤 알림 글.
*
* 손 고침 [저장]은 보이는 화면 그대로 저장(초기화 없음) — 초기화 기준 = 추천 [적용]만.
* 지워질 수 = 서버 저장분(서버가 지우는 것 · 응답 `route_reset.dropped` 와 같은 셈).
* ========================================================================== */
/** 서버 저장분에서 지워질 수 — 사용자 구조물 · 관 · 추가 측점(응답 `route_reset.dropped` 와 같은 칸). */
export interface RouteResetCounts {
structures: number;
pipes: number;
plusStations: number;
}
/** 서버 응답 `route_reset` — 초기화했나 · 지운 수. */
export interface RouteReset {
reset: boolean;
dropped?: { structures?: number; pipes?: number; plus_stations?: number };
}
/** 추천 [적용] 확인 창 글. */
export function routeResetConfirmText(counts: RouteResetCounts): string {
return (
`추천 노선을 [적용]하면 초기화합니다 — 구조물 ${counts.structures} · 관 ${counts.pipes} · ` +
`추가 측점 ${counts.plusStations} · 종단 · 횡단 손 입력이 지워지고 ` +
`새 노선으로 다시 셉니다(세류선 교차 관 · 측점 · 초기 종단 · 횡단). 적용할까요?`
);
}
/** 적용 뒤 알림 한 줄 — 초기화가 아니면 null. */
export function routeResetNotice(
reset: RouteReset | null | undefined,
crossing = 0,
): string | null {
if (!reset?.reset) return null;
const dropped = reset.dropped ?? {};
return (
`노선이 바뀌어 초기화했습니다 — 지움: 구조물 ${dropped.structures ?? 0} · 관 ${dropped.pipes ?? 0} · ` +
`추가 측점 ${dropped.plus_stations ?? 0} · 새 교차 관 ${crossing}`
);
}
@@ -18,7 +18,6 @@
import {
curveSpans,
curveStations,
snapCurveStations,
stationFrameAt,
stationsAlong,
} from "./B05_Profile_UI_RouteEdit_Frame";
@@ -239,31 +238,6 @@ export interface StationsOptions {
plus?: ReadonlyArray<number>;
}
/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */
function gridIndices(
original: number[],
snapped: number[],
intervalM: number,
): Map<number, number> {
const has = (list: number[], value: number): boolean =>
list.some((other) => Math.abs(other - value) < 1e-9);
const gone = original
.map((value, index) => ({ value, index }))
.filter((o) => !has(snapped, o.value));
const out = new Map<number, number>();
for (const value of snapped) {
const same = original.findIndex((other) => Math.abs(other - value) < 1e-9);
if (same >= 0) {
out.set(value, same);
continue;
}
const near = gone.sort((a, b) => Math.abs(a.value - value) - Math.abs(b.value - value))[0];
out.set(value, near ? near.index : Math.round(value / intervalM));
if (near) gone.splice(gone.indexOf(near), 1);
}
return out;
}
/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */
const lineLength = (line: ReadonlyArray<Vertex>): number =>
line.reduce(
@@ -283,20 +257,9 @@ export function buildStations(
if (line.length < 2) return [];
const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M;
const total = lineLength(line);
const original = stationsAlong(
line,
intervalM,
options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT,
);
// 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함
const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT);
const spans = designSpans(line, curves);
const grid = options.stationSnap
? snapCurveStations(
original,
spans.map(([bc, ec]) => [bc, ec] as const),
options.stationSnapStep,
)
: original;
const gridIndex = gridIndices(original, grid, intervalM);
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
@@ -341,7 +304,7 @@ export function buildStations(
const tangent: Vertex = [frame.left[1], -frame.left[0]];
const mark = planted.get(chainage);
let kind: StationKind = "grid";
let key = stationKey("grid", gridIndex.get(chainage) ?? Math.round(chainage / intervalM));
let key = stationKey("grid", Math.round(chainage / intervalM));
if (index === 0 || Math.abs(chainage - total) <= 1e-6) {
kind = "end";
key = stationKey("end", index === 0 ? "BP" : "EP");
@@ -6,10 +6,10 @@
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
*
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` ·
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2 · 280-4) `stationsAlong` · `curveSpans` · `curveStations` ·
* `mcScale` ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
* 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
* `curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
* `test_280_4_stations.py` · 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
* ========================================================================== */
type Vertex = [number, number];
@@ -160,29 +160,7 @@ export function curveSpans(
* (390-5). `_mc_scale` 짝. */
export const mcScale = (stepM?: number | null): number => (stepM != null && stepM < 1 ? 10 : 1);
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC·s+0.5)/s 로 옮김(s = `mcScale`) ·
* 측점 없는 호는 그대로 · 같은 값은 하나로(오름차순). `snap_curve_stations` 짝. */
export function snapCurveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
stepM?: number | null,
): number[] {
const scale = mcScale(stepM);
const values = [...stations];
for (const [start, end] of spans) {
const middle = (start + end) / 2;
let nearest = -1;
values.forEach((value, index) => {
if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return;
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
nearest = index;
});
if (nearest >= 0) values[nearest] = Math.floor(middle * scale + 0.5) / scale;
}
return [...new Set(values)].sort((a, b) => a - b);
}
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 관 · 구조물)이
* 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */
export function curveStations(
stations: ReadonlyArray<number>,
+26 -10
View File
@@ -296,13 +296,19 @@ async def _regenerate_structure_stations(
return made
class StationsUnsynced(RuntimeError):
"""맞출 구조물 측점이 있는데 샘플링 조건이 없음 — 저장은 그대로 · 부른 쪽이 「맞춤 실패」
알림(280-3)."""
async def ensure_structure_stations(project_id: UUID) -> int:
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
곡선 측점이 지금 관 · 구조물과 어긋나도 맞춤(407-3 · [저장] 의 관 · 구조물 차례 뒤).
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
만들 수 없으면(노선 · 샘플링 조건 없음 · 빠진 것 없음) 0 — 저장을 막지 않는다.
노선이 없거나 맞출 것이 없으면 0 · 맞출 것이 있는데 샘플링 조건이 없으면 `StationsUnsynced`
(부른 쪽이 저장은 그대로 두고 응답 `stations_synced` 거짓 · 280-3).
"""
paths = await _project_paths(project_id)
if paths is None:
@@ -326,10 +332,9 @@ async def ensure_structure_stations(project_id: UUID) -> int:
return 0
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
logger.warning(
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
raise StationsUnsynced(
f"구조물 측점 {len(missing) or stale}곳을 못 맞춤 — 샘플링 조건 없음"
)
return 0
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
return len(missing) or stale
@@ -417,12 +422,14 @@ class PlusStationsRequest(BaseModel):
@router.get("/{project_id}/section/plus-stations")
async def get_plus_stations(project_id: UUID) -> JSONResponse:
"""추가 측점 정본(누가거리 목록) — 없으면 빈 목록."""
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
"""추가 측점 정본(누가거리 목록 · 땅 위 자리 `stations` = 누가거리 · x · y) — 없으면 빈 목록.
화면이 노선 초안 동안 이 좌표에서 따라감(493-3 · 서버 [저장] 따라감과 같은 자리)."""
from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations
paths = await _project_paths(project_id)
chainages = await asyncio.to_thread(plus_chainages, paths[0]) if paths else []
return JSONResponse(content={"status": "success", "chainages": chainages})
rows = await asyncio.to_thread(load_plus_stations, paths[0]) if paths else []
chainages = sorted({float(row["chainage_m"]) for row in rows})
return JSONResponse(content={"status": "success", "chainages": chainages, "stations": rows})
@router.put("/{project_id}/section/plus-stations")
@@ -447,14 +454,23 @@ async def put_plus_stations(project_id: UUID, request: PlusStationsRequest) -> J
return save_plus_stations(project_root, chainages, xy)
saved = await asyncio.to_thread(_save)
await ensure_structure_stations(project_id)
synced = True
try:
await ensure_structure_stations(project_id)
except StationsUnsynced:
logger.warning(
"B06 추가 측점: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id
)
synced = False
except Exception:
logger.exception("B06 추가 측점 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "추가 측점을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "chainages": saved})
return JSONResponse(
content={"status": "success", "chainages": saved, "stations_synced": synced}
)
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
+14 -5
View File
@@ -33,6 +33,9 @@ import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys";
import { type CrossSection, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
type Pipes = PendingPipes;
/** 노선 초안 동안 상세의 틀(280-3) — old = 옛 노선 누가거리(저장 정본 · 초안 전 상세 · 초안 대신 저장분으로
* 맞춤 · 얹음) · new = 묶음이 갈아끼운 새 노선 누가거리(저장분 종류 대조 안 함) · null = 노선 초안 없음. */
export type RouteFrame = "old" | "new" | null;
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M;
const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) =>
@@ -128,9 +131,11 @@ export async function withDraftShapes(
detail: SectionDetailResponse,
projectId: string,
swapped = false,
frame: RouteFrame = null,
): Promise<void> {
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
const old = frame === "old"; // 옛 틀 = 저장분으로 얹음(280-3)
const pipesDraft = old ? null : readPendingPipes(projectId);
const structuresDraft = old ? null : readPendingStructures(projectId);
const drafts = !!(pipesDraft || structuresDraft);
if (!drafts && !swapped && !drafted.has(detail)) return;
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
@@ -161,11 +166,13 @@ export async function withDraftStations(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
frame: RouteFrame = null,
): Promise<void> {
const turn = (turns.get(detail) ?? 0) + 1;
turns.set(detail, turn);
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
const old = frame === "old"; // 새 틀 초안을 옛 틀 측점에 견주지 않음(280-3)
const pipesDraft = old ? null : readPendingPipes(projectId);
const structuresDraft = old ? null : readPendingStructures(projectId);
const all = [
...detail.cross_sections.filter((section) => !section.preview),
...(parked.get(detail) ?? []),
@@ -173,7 +180,7 @@ export async function withDraftStations(
let live = all;
let stale: CrossSection[] = [];
// 추가 측점 초안(368-2)도 측점 계획을 바꿈 — 계획은 그 초안을 스스로 읽음
if (!(pipesDraft || structuresDraft || readPlusDraft(projectId))) {
if (!(pipesDraft || structuresDraft || (!old && readPlusDraft(projectId)))) {
// 초안 없음 = 저장분 계획으로 열쇠 주인만 — 관 = 저장 id(계산 묶음 열쇠와 같음 · 첫 옮김도 열쇠째 · 415 H1)
const inputs = await statusOf(projectId, null, null);
if (inputs) {
@@ -210,7 +217,9 @@ export async function withDraftStations(
]
.sort()
.join(",");
// 새 틀 = 저장분(옛 틀 누가거리)과 같은 m 로 견주지 않음 — 측점 목록은 묶음 갈아끼움 몫(280-3)
const replaced = (section: CrossSection): boolean => {
if (frame === "new") return false;
const was = kindsAt(oldPipes, oldStructures, section.chainage_m);
return was !== "" && was !== kindsAt(pipes, structures, section.chainage_m);
};
+11 -2
View File
@@ -45,6 +45,7 @@ import {
keepHeldDetail,
keepSavedDetail,
savedDesigns,
savedSectionDetail,
sessionDetail,
} from "./B06_Section_Section_Canon";
import { fetchSectionDetail } from "./B06_Section_Api_Fetch";
@@ -90,8 +91,16 @@ async function withDraftWalls(
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
setPlusStations(() => plusStationsOf(projectId)); // 측점 계획이 읽을 추가 측점(368-2)
await loadSavedPlusStations(projectId);
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
await withDraftShapes(detail, projectId);
// 노선 초안 동안 틀(280-3) — 저장 정본 · 초안 전 상세 = 옛 노선 틀 · 그 밖 = 묶음이 갈아끼운 새 노선 틀
const frame =
readState("route-draft", projectId) === null // 가게 `routeDraft` 칸(세션)
? null
: detail === savedSectionDetail(projectId, routeId) ||
detail === heldSectionDetail(projectId, routeId)
? "old"
: "new";
await withDraftStations(detail, projectId, routeId, frame); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
await withDraftShapes(detail, projectId, false, frame);
await withAdjustsAndAreas(detail, projectId, routeId);
// 초안 자리 미리 뜬 횡단 = 서버가 저장 계획선으로 셈 · 묶음 cross 뒤에 얹힐 수 있음 — 다 돈 뒤 지금 계획선과
// 갈리면 cross 한 번 더(387-8 · 낡음 판정 = 가게 몫 · 종단 판이 접혀 그리기가 안 돌아도)
@@ -7,13 +7,8 @@
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import {
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
} from "./B06_Section_UI_Cross_Design";
import { buildDesignControls, sectionModeLabel } from "./B06_Section_UI_Cross_Design";
import { type FillSlopeLength, fillSlopeLengths } from "./logic/B06_Section_UI_Cross_Fit";
import {
type DesignChangeHandler,
@@ -159,27 +154,20 @@ export function appendCardHeader(
card.append(header);
}
/** 하단 정보행 — 왼쪽 = 선 공용 칸(370-3 · 안 고르면 암 경계선) · 오른쪽 = (암 지반일 때) 암 절토 경사. */
/** 하단 정보행 — 왼쪽 = 선 공용 칸(370-3 · 안 고르면 빈 칸 · 479) · 오른쪽 = (암 지반일 때) 암 절토 경사. */
export function appendCardFooter(
card: HTMLElement,
section: CrossSection,
rockBoundary?: RockBoundaryControl,
cutSlope?: CutSlopeControl,
/** 설계선 고름 공용 칸(370-3 · `_Cross_Line_Cell`) — 있으면 왼쪽 칸(안 고르면 암 · 고르면 그 선). */
/** 설계선 고름 공용 칸(370-3 · `_Cross_Line_Cell`) — 있으면 왼쪽 칸(안 고르면 빈 칸 · 고르면 그 선). */
lineCell?: HTMLElement,
): void {
// 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 하단 띠는 암 제어만 · 비어도 띠 높이 그대로
// 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 비어도 띠 높이 그대로
const footer = document.createElement("footer");
if (lineCell) {
lineCell.classList.add("b06-cross-card__rockb");
footer.append(lineCell);
}
// 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만 · 설계선 고름이 없는 카드.
else if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
}
// 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다
// (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다.
if (cutSlope && section.design?.geometry_preset === "rock") {
+2 -13
View File
@@ -28,7 +28,7 @@ export {
} from "./B06_Section_UI_Cross_Design_Surface";
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
import { buildLineCell, rockCellState, type RockCellSpec } from "./B06_Section_UI_Cross_Line_Cell";
import type { RockCellSpec } from "./B06_Section_UI_Cross_Line_Cell";
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
interface ReflowCard {
@@ -207,18 +207,7 @@ function toggle(
return { wrap, button };
}
/** 암 경계선 상/하/리셋 컨트롤 — 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)의 「안 고름」 꼴. 설계선 고름이
* 없는 카드(설계 없음)에서 띠 왼쪽 칸으로 씀. */
export function buildRockBoundaryControl(
section: CrossSection,
control: RockBoundaryControl,
): HTMLElement {
const cell = buildLineCell();
cell.show(rockCellState(rockCellSpec(section, control)));
return cell.root;
}
/** 암 경계선 칸 제원 — 공용 칸(안 고름 · 암 고름)이 같이 씀. */
/** 암 경계선 칸 제원 — 공용 칸 암 고름(479 — 안 고르면 빈 칸). */
export function rockCellSpec(section: CrossSection, control: RockBoundaryControl): RockCellSpec {
return {
legend: L("B06_Design_RockBoundary_Legend"),
+28 -29
View File
@@ -5,7 +5,7 @@
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)에
* 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 암 경계선 칸 · 떠 있는 선 조정창 · 우측 위 라벨 걷음).
* 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 빈 칸 · 479 · 떠 있는 선 조정창 · 우측 위 라벨 걷음).
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각 쪽 사면을 노견째 ◀ ↺ ▶ · 방향키 · 0.1 m 로 옮김(139-6 쉬프트 · 151-3 ·
@@ -444,7 +444,7 @@ export function attachDesignLinePick(input: {
toes?: ReturnType<typeof toePoints>;
/** 있으면 쪽마다 노견 끝 = 원지반(절·성토 없음 · 379-1) — 손잡이 값 앞 알림. */
bare?: ReturnType<typeof edgeOnGround>;
/** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기) · 안 고른 때 공용 칸 = 암(370-3). */
/** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기 · 370-3) · 안 고르면 빈 칸(479). */
rock?: RockCellSpec;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
@@ -627,33 +627,32 @@ export function attachDesignLinePick(input: {
const toe = toeSide ? input.toes?.[toeSide] : null;
const dims = piece ? pieceLabel(design, piece).replace("\n", " · ") : "";
const bare = toeSide && input.bare?.[toeSide] ? "절·성토 없음 · " : ""; // 379-1
// 아래 띠 공용 칸(370-3) — 안 고르면 암(암 지반만) · 고르면 그 선 제목 · 버튼 · 값 한 줄
const state: LineCellState | null =
onRock || (!chosen && rock)
? rockCellState(rock as RockCellSpec)
: onCenter
? { title: "중심점", value: CENTER_HINT, buttons: {} }
: toe
? {
title: `사면 이동 ${sideName}`,
value: `${bare}노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`,
buttons: shift ? sideways(true) : {},
}
: !piece
? null
: axis === "y"
? { title: "노면 높이", value: `${dims} · ▲▼ = 종단 계획고`, buttons: {} }
: axis === "depth" && side
? {
title: `소단 높이 ${sideName}`,
value: `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m · ${dims}`,
buttons: berm ? lift : {},
}
: {
title: `사면 이동 ${sideName}`,
value: dims + moved,
buttons: shift ? sideways(true) : {},
};
// 아래 띠 공용 칸(370-3) — 안 고르면 빈 칸(479) · 고르면 그 선 제목 · 버튼 · 값 한 줄
const state: LineCellState | null = onRock
? rockCellState(rock as RockCellSpec)
: onCenter
? { title: "중심점", value: CENTER_HINT, buttons: {} }
: toe
? {
title: `사면 이동 ${sideName}`,
value: `${bare}노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`,
buttons: shift ? sideways(true) : {},
}
: !piece
? null
: axis === "y"
? { title: "노면 높이", value: `${dims} · ▲▼ = 종단 계획고`, buttons: {} }
: axis === "depth" && side
? {
title: `소단 높이 ${sideName}`,
value: `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m · ${dims}`,
buttons: berm ? lift : {},
}
: {
title: `사면 이동 ${sideName}`,
value: dims + moved,
buttons: shift ? sideways(true) : {},
};
cell.show(state);
const open =
onCenter ||
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Line_Cell.ts
* 횡단 카드 아래 띠 왼쪽 **공용 칸**(370-3) — 고른 선의 제목 · 축에 맞는 버튼 · 값 한 줄.
*
* · 안 고르면 암 경계선 ▲▼↺ · 값(암 지반만 · 토사는 빈 칸 · 띠 높이 그대로)
* · 안 고르면 빈 칸(479 · 띠 높이 그대로)
* · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암) — `_Cross_Design_Pick` 이 상태를 넣음
* · 버튼 다섯 칸(◀ ▲ ↺ ▼ ▶)은 한 벌 · 상태마다 보임 · 툴팁 · 동작만 바꿈 · 방향키는 보이는 슬롯만 찾음
* · ▲▼ 길게 누름 = 0.5 초 뒤 0.1 초마다 반복(암 · B05 종단 선 제어 꼴)
+1 -1
View File
@@ -680,6 +680,6 @@ export function createCrossSectionCard(
card.append(chartWrap);
}
appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell); // 왼쪽 = 선 공용 칸(370-3)
appendCardFooter(card, section, cutSlope, linePick?.cell); // 왼쪽 = 선 공용 칸(370-3)
return card;
}
@@ -36,6 +36,9 @@ export interface CadDrawing {
oy: number;
dy: number;
mm_per_m: number;
/** 가로 · 세로 mm/m(485) — 옛 저장본엔 없음 = `mm_per_m`. */
mm_per_m_x?: number;
mm_per_m_y?: number;
x0: number;
x1: number;
}[];
@@ -186,6 +189,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
);
}
/** B07 단계(4) 완료(281) — 페이지 이동 저장 뒤 B08 · B09 로 갈 때(확정 잠금 없음). */
export function completeDesignStage(projectId: string): Promise<void> {
return requestJson(`/projects/${projectId}/design-drawings-complete`, { method: "POST" });
}
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */
export interface CrossLayout {
default: [number, number];
@@ -205,6 +213,35 @@ export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise
});
}
/** 도면 축척 종류(485) — 표준 횡단 · 평면도 · 구조물은 뺌. */
export type DrawingScaleKind = "longitudinal" | "cross" | "mass_haul" | "development";
/** 축척 분모 — null = 자동(토적 가로 · 전개도) · 범위 = [최소, 최대]. */
export type DrawingScaleAxes = { x: number | null; y: number | null };
export interface DrawingScales {
scales: Record<DrawingScaleKind, DrawingScaleAxes>;
defaults: Record<DrawingScaleKind, DrawingScaleAxes>;
ranges: Record<DrawingScaleKind, { x: [number, number]; y: [number, number] }>;
/** 종류마다 저장본(확정) 장 수 — 바꾸면 풀리므로 화면이 먼저 물음. */
saved: Record<DrawingScaleKind, number>;
/** PUT 응답만 — 이번에 풀린 저장본 장 수. */
invalidated?: Partial<Record<DrawingScaleKind, number>>;
}
export function fetchDrawingScales(projectId: string): Promise<DrawingScales> {
return requestJson(`/projects/${projectId}/drawing-scales`);
}
/** 축척 저장 — 보낸 종류만 바꿈 · 값이 바뀐 종류의 저장본은 서버가 풂. */
export function saveDrawingScales(
projectId: string,
scales: Partial<Record<DrawingScaleKind, DrawingScaleAxes>>,
): Promise<DrawingScales> {
return requestJson(`/projects/${projectId}/drawing-scales`, {
method: "PUT",
body: JSON.stringify({ scales }),
});
}
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
export interface PlanKind {
id: string;
@@ -7,7 +7,7 @@
* 오프스크린 SVG 대신 아래 최소 DOM 위에서 돌려 측점마다 범위(m)를 낸다.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴) }
* 입력 { detail: 종횡단 상세(API와 같은 꼴), mm_per_m_x?, mm_per_m_y? } — 횡단 축척(485 · 없으면 1/100)
* 출력 { extents: [{ chainage_m, x_min, x_max, y_min, y_max }] } — 오프셋·표고(m)
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
@@ -17,7 +17,7 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
import "./B07_DesignDetail_Node_Dom"; // 최소 DOM(globalThis.document) — 그리기 코드보다 먼저
import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay";
/** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */
/** 기본 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차 · 글자 폭이 종이 mm 라 화면과 같아야 한다. */
const MM_PER_M = 10;
const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12];
@@ -81,7 +81,13 @@ if (!inputPath || !outputPath) {
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { detail?: SectionDetailResponse };
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
detail?: SectionDetailResponse;
mm_per_m_x?: number;
mm_per_m_y?: number;
};
const mmX = input.mm_per_m_x ?? MM_PER_M;
const mmY = input.mm_per_m_y ?? MM_PER_M;
const sections = input.detail?.cross_sections ?? [];
const extents: Record<string, number>[] = [];
for (const section of sections) {
@@ -90,8 +96,8 @@ for (const section of sections) {
entities = overlayEntities(
section,
sections,
(offset) => offset * MM_PER_M,
(elevation) => -(elevation * MM_PER_M),
(offset) => offset * mmX,
(elevation) => -(elevation * mmY),
OPEN_FRAME,
);
} catch (error) {
@@ -100,7 +106,8 @@ for (const section of sections) {
}
const extent = entities?.length ? extentOf(entities) : null;
if (!extent) continue;
const [xMin, xMax, yMin, yMax] = extent.map((value) => value / MM_PER_M);
const [xMin, xMax] = [extent[0] / mmX, extent[1] / mmX];
const [yMin, yMax] = [extent[2] / mmY, extent[3] / mmY];
extents.push({
chainage_m: section.chainage_m,
x_min: xMin,
@@ -560,6 +560,9 @@ export interface CrossPlacement {
oy: number;
dy: number;
mm_per_m: number;
/** 가로 · 세로 mm/m(485 · X ≠ Y 허용) — 옛 저장본엔 없음 = `mm_per_m`. */
mm_per_m_x?: number;
mm_per_m_y?: number;
x0: number;
x1: number;
/** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */
@@ -600,9 +603,10 @@ export function placeStructureEntities(
);
const frame = placement.frame;
if (!section || !frame) continue;
const x = (offset: number): number => offset * placement.mm_per_m + placement.ox;
const y = (elevation: number): number =>
-((elevation - placement.dy) * placement.mm_per_m + placement.oy);
const mmX = placement.mm_per_m_x ?? placement.mm_per_m;
const mmY = placement.mm_per_m_y ?? placement.mm_per_m;
const x = (offset: number): number => offset * mmX + placement.ox;
const y = (elevation: number): number => -((elevation - placement.dy) * mmY + placement.oy);
let entities: Entity[];
try {
const drawn = overlayEntities(section, sections, x, y, frame);
@@ -420,8 +420,22 @@ def _cross_line_entities(
return entities
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0).
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0) — 기본값 ·
# 그릴 때는 프로젝트 축척(`cross_mm`)을 쓴다(485).
CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS
def cross_mm() -> tuple[float, float]:
"""이 요청 프로젝트의 횡단 (가로, 세로) mm/m — X ≠ Y 허용(485)."""
# 도각 모듈이 이 모듈을 들여오므로 순환을 피해 함수 안에서 들여온다.
from B07_DesignDetail.B07_DesignDetail_Engine_Template import drawing_scale
scale = drawing_scale("cross")
return 1000.0 / float(scale["x"] or DRAWING_SCALE_CROSS), 1000.0 / float(
scale["y"] or DRAWING_SCALE_CROSS
)
# 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m).
CROSS_SECTION_MARGIN_M = 1.5
# 단면 블록 테두리 ↔ 안쪽 그림 간격(mm) — 한 장에 최대한 많이 담는다.
@@ -531,6 +545,7 @@ def build_cross_drawing(
cross_table_width,
)
mm_x, mm_y = cross_mm()
# 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유.
x0, x1 = _section_window(raw_ground, raw_design, overlay=overlay)
ground_clipped = _clip_polyline(raw_ground, x0, x1)
@@ -552,10 +567,10 @@ def build_cross_drawing(
+ cross_note_block_height(design, table_scale)
+ 8.0
)
oy = table_bottom + stack + own_half_height * CROSS_MM
oy = table_bottom + stack + own_half_height * mm_y
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * CROSS_MM + ox, (point[1] - dy) * CROSS_MM + oy)
return (point[0] * mm_x + ox, (point[1] - dy) * mm_y + oy)
entities: list[dict[str, Any]] = []
ground = polyline_entity(
@@ -574,8 +589,8 @@ def build_cross_drawing(
# 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면
# 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다.
center_x = ox if table_bottom is not None else ox + (x0 + x1) / 2.0 * CROSS_MM
half_height = own_half_height * CROSS_MM
center_x = ox if table_bottom is not None else ox + (x0 + x1) / 2.0 * mm_x
half_height = own_half_height * mm_y
table_bottom = oy - half_height
if quantity_table is not None:
table_top = oy - half_height - 8.0
@@ -595,8 +610,8 @@ def build_cross_drawing(
pad = CROSS_BORDER_PAD_MM
# 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1)
table_half = cross_table_width(table_scale) / 2.0
frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad
frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad
frame_left = min(ox + x0 * mm_x, center_x - table_half) - pad
frame_right = max(ox + x1 * mm_x, center_x + table_half) + pad
frame_top = oy + half_height + pad
frame_bottom = table_bottom - pad
corners = [
@@ -622,7 +637,10 @@ def build_cross_drawing(
"ox": ox,
"oy": oy,
"dy": dy,
"mm_per_m": CROSS_MM,
# 옛 칸 `mm_per_m` = 가로 · 세로가 다르면 `_x` · `_y`(485)
"mm_per_m": mm_x,
"mm_per_m_x": mm_x,
"mm_per_m_y": mm_y,
"x0": x0,
"x1": x1,
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
@@ -30,6 +30,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import station_text, table_rows
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_area,
drawing_scale,
frame_entities,
scale_fields,
)
@@ -201,7 +202,10 @@ def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, flo
def _vertical(rows: list[dict[str, Any]], mm_h: float) -> float:
"""세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게."""
"""세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게 · 정했으면 그 값(485)."""
fixed = drawing_scale("development")["y"]
if fixed:
return 1000.0 / fixed
tallest = max((r["height_m"] for r in rows if r["height_m"]), default=None)
if not tallest:
return _MIN_H
@@ -239,7 +243,12 @@ def layout(rows, area, table_box, mm_h, strict=True):
def _choose(rows, area, table_box):
"""처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고)."""
"""처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고).
가로 축척을 정했으면(485) 그 값 하나만 — 넘쳐도 그대로."""
fixed = drawing_scale("development")["x"]
if fixed:
return fixed, layout(rows, area, table_box, 1000.0 / fixed, strict=False)
for denominator in SCALES:
placed = layout(rows, area, table_box, 1000.0 / denominator)
if placed is not None:
@@ -366,6 +375,18 @@ def build_development_drawing(
table_w, table_h = sum(_COLUMNS), _ROW_H * (len(lines) + 3)
table_box = (ax1 - _MARGIN - table_w, ay0 + _MARGIN, ax1 - _MARGIN, ay0 + _MARGIN + table_h)
denominator, placed = _choose(rows, area, table_box)
vertical = drawing_scale("development")["y"]
# 세로를 정했고 가로와 다르면 이름표(H · V) — 자동 세로는 지금처럼 가로만(485)
scales = (
(("H", denominator), ("V", vertical))
if vertical and vertical != denominator
else (("", denominator),)
)
scale_text = (
f"H=1:{denominator:,} V=1:{vertical:,}"
if vertical and vertical != denominator
else f"S=1:{denominator:,}"
)
def station(chainage: float) -> str:
return station_text(chainage, interval_m)
@@ -382,7 +403,7 @@ def build_development_drawing(
),
_text_entity(
f"{drawing_id}:scale",
f"S=1:{denominator:,}",
scale_text,
ax1 - _MARGIN,
ay1 - 14.0,
LABEL_LAYER_ID,
@@ -425,7 +446,7 @@ def build_development_drawing(
area,
fit=False,
bottom_left=True,
fields={"도면명": title, **scale_fields(("", denominator))},
fields={"도면명": title, **scale_fields(*scales)},
)
return {
"format": DRAWING_FORMAT,
@@ -42,17 +42,24 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
station_no_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
entities_bbox,
frame_entities,
scale_fields,
usable_area,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,000 -> 1.0, 1/200 -> 5.0).
MM_H = 1000.0 / DRAWING_SCALE_LONG_H
MM_V = 1000.0 / DRAWING_SCALE_LONG_V
# 축척 = 이 요청 프로젝트 값(485 · 기본 1/1,000 · 1/200).
def _mm_h() -> float:
return 1000.0 / float(drawing_scale("longitudinal")["x"])
def _mm_v() -> float:
return 1000.0 / float(drawing_scale("longitudinal")["y"])
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
LONG_GRID_LAYER_ID = "b08-long-grid"
@@ -175,7 +182,7 @@ def _graph_grid_entities(
"""
# 기준선을 종이 y=0으로 두고 표고 차이만 MM_V로 올린다.
datum_y = 0.0
top_y = (top_m - datum_m) * MM_V
top_y = (top_m - datum_m) * _mm_v()
entities: list[dict[str, Any]] = [
# 기준선(X축)
_line_entity(
@@ -190,7 +197,7 @@ def _graph_grid_entities(
level_m = datum_m
tick_index = 0
while level_m <= top_m + 1e-6:
level = (level_m - datum_m) * MM_V
level = (level_m - datum_m) * _mm_v()
entities.append(
_line_entity(
f"{drawing_id}:lgyt{tick_index}",
@@ -251,8 +258,8 @@ def _curve_row_entities(
evc = curve.get("evc_m")
if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)):
continue
start = max(float(bvc) * MM_H, x0)
end = min(float(evc) * MM_H, x1)
start = max(float(bvc) * _mm_h(), x0)
end = min(float(evc) * _mm_h(), x1)
if end <= start:
continue
seed = f"{drawing_id}:lcurve:{index}"
@@ -373,8 +380,8 @@ def _grade_row_entities(
grade = segment.get("grade_percent")
if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)):
continue
start = max(float(from_m) * MM_H, x0) + radius
end = min(float(to_m) * MM_H, x1) - radius
start = max(float(from_m) * _mm_h(), x0) + radius
end = min(float(to_m) * _mm_h(), x1) - radius
if end <= start:
continue
seed = f"{drawing_id}:lgrade:{index}"
@@ -405,10 +412,10 @@ def _grade_row_entities(
# 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고
for index, (chainage, is_route_end) in enumerate(
_grade_break_points(segments, x0 / MM_H, x1 / MM_H, route_start, route_end)
_grade_break_points(segments, x0 / _mm_h(), x1 / _mm_h(), route_start, route_end)
):
# chainage는 월드 거리 — 계획고 조회에 그대로 쓰고, 좌표에는 MM_H를 곱한다.
x_paper = chainage * MM_H
x_paper = chainage * _mm_h()
seed = f"{drawing_id}:lgradebp:{index}"
if is_route_end:
entities.append(
@@ -456,8 +463,8 @@ def _long_table_entities(
route_end: float,
) -> list[dict[str, Any]]:
"""종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값."""
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
header_width = max(interval_m * MM_H, 12.0)
chainages = [float(s["chainage_m"]) * _mm_h() for s in stations]
header_width = max(interval_m * _mm_h(), 12.0)
left = chainages[0] - header_width
right = chainages[-1]
entities: list[dict[str, Any]] = []
@@ -594,7 +601,8 @@ def _long_content(
)
interval_m = infer_station_interval(all_stations)
# 좌표는 종이 mm — 거리에 MM_H, 표고는 기준선(datum) 기준으로 MM_V를 곱한다.
# 좌표는 종이 mm — 거리에 가로 mm/m, 표고는 기준선(datum) 기준으로 세로 mm/m 를 곱한다.
mm_h, mm_v = _mm_h(), _mm_v()
elevations = [y for _x, y in [*ground_points, *design_points]]
min_e = min(elevations) if elevations else 0.0
max_e = max(elevations) if elevations else 10.0
@@ -603,7 +611,7 @@ def _long_content(
top_m = max_e + 3.0
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * MM_H, (point[1] - datum_m) * MM_V)
return (point[0] * mm_h, (point[1] - datum_m) * mm_v)
entities: list[dict[str, Any]] = []
ground = polyline_entity(
@@ -618,7 +626,7 @@ def _long_content(
entities.append(design)
if stations:
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
chainages = [float(s["chainage_m"]) * mm_h for s in stations]
entities.extend(
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_m, top_m)
)
@@ -647,6 +655,7 @@ def build_longitudinal_drawing(
) -> dict[str, Any]:
"""종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다."""
entities = _long_content(longitudinal, drawing_id, chunk)
scale = drawing_scale("longitudinal")
# A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다.
# 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다.
bbox = entities_bbox(entities)
@@ -659,7 +668,7 @@ def build_longitudinal_drawing(
bottom_left=True,
fields={
"도면명": "종단면도",
**scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)),
**scale_fields(("H", scale["x"]), ("V", scale["y"])),
},
)
)
@@ -36,6 +36,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
entities_bbox,
frame_entities,
scale_fields,
@@ -48,10 +49,15 @@ from config.config_system import (
)
# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). 세로는 고정이다 — 축척 분모가
# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다.
# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다. 기본값 · 그릴 때는
# 프로젝트 축척(485 · 세로 분모 50,000 = 1 mm 당 50 ㎥)을 쓴다.
MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM
def _mm_v() -> float:
return 1000.0 / float(drawing_scale("mass_haul")["y"])
def auto_scale_h(length_m: float) -> int:
"""노선 연장에 맞는 가로 축척 분모 — **한 장에 들어가는 가장 큰 그림**을 고른다.
@@ -131,7 +137,7 @@ def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]:
def _paper(x_m: float, volume_m3: float, mm_h: float) -> tuple[float, float]:
return (x_m * mm_h, volume_m3 * MM_V)
return (x_m * mm_h, volume_m3 * _mm_v())
def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float:
@@ -145,15 +151,15 @@ def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float)
def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]:
"""좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선."""
entities: list[dict[str, Any]] = []
top = max_v * MM_V
bottom = min_v * MM_V
top = max_v * _mm_v()
bottom = min_v * _mm_v()
entities.append(
_line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR)
)
start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3
value = start
while value <= max_v + 1e-6:
y = value * MM_V
y = value * _mm_v()
entities.append(
_line_entity(
f"{drawing_id}:axis:tick:{value:.0f}",
@@ -336,7 +342,7 @@ def _band_entities(
anchor = _paper((haul_from + haul_to) / 2.0, mid_level, mm_h)
top_m3 = _curve_top_at(curve, boundary_from, boundary_to)
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
center_y = top_m3 * MM_V + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER)
center_y = top_m3 * _mm_v() + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER)
entities.extend(
_balloon_entities(
drawing_id,
@@ -383,7 +389,7 @@ def _residual_entities(
]
anchor = _paper((from_m + to_m) / 2.0, level, mm_h)
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
center_y = bottom_m3 * MM_V - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER)
center_y = bottom_m3 * _mm_v() - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER)
entities.extend(
_balloon_entities(
drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect"
@@ -511,7 +517,8 @@ def build_mass_haul_drawing(
# 가로 축척은 노선 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림
# (2026-09-03 사용자 결정: 길이별 자동 축척).
length_m = max(x for x, _v in curve) - min(x for x, _v in curve)
scale_h = auto_scale_h(length_m)
scale = drawing_scale("mass_haul")
scale_h = scale["x"] or auto_scale_h(length_m) # 가로 None = 자동(485)
mm_h = 1000.0 / scale_h
entities: list[dict[str, Any]] = []
@@ -541,7 +548,7 @@ def build_mass_haul_drawing(
# 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에
# 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측).
graph_bottom = min(min_v * MM_V, 0.0)
graph_bottom = min(min_v * _mm_v(), 0.0)
drawn = entities_bbox(entities)
if drawn:
graph_bottom = min(graph_bottom, drawn[1])
@@ -565,7 +572,7 @@ def build_mass_haul_drawing(
TABLE_LABEL_COLOR,
)
)
scale_text = f"SCALE H=1:{scale_h:,} V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}"
scale_text = f"SCALE H=1:{scale_h:,} V=1:{scale['y']:,}"
entities.append(
_text_entity(
f"{drawing_id}:scale",
@@ -17,7 +17,6 @@ from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
CROSS_BORDER_PAD_MM,
CROSS_MM,
CROSS_TABLE_LAYER_ID,
DESIGN_LAYER_ID,
DRAWING_FORMAT,
@@ -30,6 +29,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_layer,
_section_window,
build_cross_drawing,
cross_mm,
points_from_samples,
polyline_entity,
)
@@ -39,11 +39,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_table_width,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
frame_entities,
scale_fields,
usable_area,
)
from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
@@ -95,10 +95,11 @@ def section_block_size(
ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
if overlay is not None:
ys.extend(overlay[2:])
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM
mm_x, mm_y = cross_mm()
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * mm_y
width = 2.0 * max(
(x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM,
(x1 - x0) / 2.0 * mm_x + _BORDER_PAD_MM,
cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM,
)
# 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유
@@ -254,13 +255,20 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
if entities:
entities.extend(_grid_lines(sheet["id"], cols, rows))
scale = drawing_scale("cross")
# 가로 · 세로가 같으면 분모 하나 · 다르면 이름표(H · V)(485)
scales = (
(("", scale["x"]),)
if scale["x"] == scale["y"]
else (("H", scale["x"]), ("V", scale["y"]))
)
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3).
entities.extend(
frame_entities(
sheet["id"],
(-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0),
fit=False,
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
fields={"도면명": "횡단면도", **scale_fields(*scales)},
)
)
@@ -62,10 +62,12 @@ def _entity_id(index: int) -> str:
def _color(entity: Any) -> str:
"""DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다."""
"""DXF 색 번호를 화면 색으로. BYLAYER 면 레이어 색(488 · B07 붙이기 · M02 불러오기 같이) · BYBLOCK 은 기본색."""
try:
aci = int(entity.dxf.color)
if aci in (0, 256): # BYBLOCK · BYLAYER
if aci == 256: # BYLAYER
return _layer_color(entity.doc, entity.dxf.layer) or _DEFAULT_COLOR
if aci == 0: # BYBLOCK
return _DEFAULT_COLOR
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
@@ -27,6 +27,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
FRAME_LAYER_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root
from config.config_system import (
DRAWING_SCALE_CROSS,
DRAWING_SCALE_LONG_H,
DRAWING_SCALE_LONG_V,
DRAWING_SCALE_MASSHAUL_V_M3_MM,
)
from M02_MasterTemplete.M02_Template_Layers import (
delete_template,
initial_dir,
@@ -85,6 +92,51 @@ def add_title_fields(extra: dict[str, str]) -> None:
_title_fields.set({**_title_fields.get(), **(extra or {})})
# 도면 축척(485) — 프로젝트마다 `{B07 폴더}/drawing_scales.json` `{종류: {x, y}}`(분모).
# 칸이 없거나 어긋나면 기본(`config_system`) · None = 자동(토적 가로 · 전개도 가로 · 세로).
# 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다.
DRAWING_SCALES_FILE = "drawing_scales.json"
DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = {
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V},
"cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS},
# 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥)
"mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)},
"development": {"x": None, "y": None},
}
def drawing_scales_path(project_root: Path) -> Path:
return _design_root(project_root) / DRAWING_SCALES_FILE
def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | None]]:
"""종류마다 {x, y} — 파일에 양의 정수가 있으면 그것, 아니면 기본."""
# ponytail: 부를 때마다 작은 파일을 읽음(수정 시각 캐시는 같은 시각 두 번 쓰기에 낡음)
stored: Any = {}
if project_root is not None:
try:
stored = json.loads(drawing_scales_path(project_root).read_text(encoding="utf-8"))
except (OSError, ValueError):
stored = {}
if not isinstance(stored, dict):
stored = {}
scales: dict[str, dict[str, int | None]] = {}
for kind, defaults in DRAWING_SCALE_DEFAULTS.items():
row = stored.get(kind) if isinstance(stored.get(kind), dict) else {}
scales[kind] = {
axis: value
if isinstance(value := row.get(axis), int) and not isinstance(value, bool) and value > 0
else default
for axis, default in defaults.items()
}
return scales
def drawing_scale(kind: str) -> dict[str, int | None]:
"""이 요청 프로젝트의 그 종류 축척 {x, y}(분모 · None = 자동)."""
return read_drawing_scales(_project_root.get())[kind]
def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path:
"""프로젝트 작업본 도각 파일 경로(없을 수도 있다)."""
return template_path(project_dir(project_root), _KIND, name)
@@ -494,7 +546,7 @@ def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]:
도각이 `A1 = 1 :` 를 이미 찍으므로 **분모만** 낸다. A3 는 A1 도면을 절반으로 뽑는
종이라 분모가 2배다. 가로·세로 축척이 다른 도면(종단·토적)은 이름표를 붙여 함께 적는다.
값은 `config_system` 의 축척 상수에서 오며 여기서 새로 정하지 않는다.
값은 엔진이 쓴 축척(`drawing_scale`)에서 오며 여기서 새로 정하지 않는다.
"""
def text(factor: int) -> str:
@@ -111,6 +111,8 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path, bool]:
longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.")
# 목록 · 확정 · 배치도 도면과 같은 도각 · 축척으로 장을 나눈다(485 — 종단 가로 = 장 나눔)
use_project_templates(root)
return route_id, root, longitudinal_path, bypassed
@@ -557,6 +559,37 @@ async def invalidate_design_drawing(
)
@router.post("/{project_id}/design-drawings-complete")
async def complete_design_stage(project_id: UUID) -> Any:
"""B07 단계 완료 — 페이지 이동 저장(고친 도면만) 뒤 B08 · B09 로 갈 때(요구 281 · 확정 잠금 없음).
안 고친 도면은 늘 원본에서 새로 그리므로 「모든 횡단 장 확정」을 기다리지 않는다.
"""
try:
await _confirmed_source(project_id)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), _B07_STAGE_NO)
await connection.commit()
except Exception:
await connection.rollback()
raise
return {"status": "success", "project_id": str(project_id)}
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B07 단계 완료 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 단계를 완료하지 못했습니다."},
)
class CrossLayoutRequest(BaseModel):
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2)."""
@@ -19,6 +19,7 @@ from typing import Any
from uuid import UUID
from B05_Profile.B05_Profile_Engine_Sections import cross_station
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import cross_mm
from common_util.common_util_node_bundle import bundle_stale, run_bundle_json
logger = logging.getLogger(__name__)
@@ -60,14 +61,16 @@ async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[i
dump = getattr(response, "model_dump", None)
if dump is None: # JSONResponse = 실패
return {}
detail = dump(mode="json")
# 종이 mm 글자 폭이 m 로 바뀌는 몫이 축척에 기댐 — 프로젝트 횡단 축척을 같이 넘김(485)
mm_x, mm_y = cross_mm()
payload = {"detail": dump(mode="json"), "mm_per_m_x": mm_x, "mm_per_m_y": mm_y}
key = hashlib.sha1(
json.dumps(detail, sort_keys=True, default=str).encode("utf-8")
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
# 번들이 낡았으면(화면 TS 가 바뀜) 기억을 버리고 다시 잰다 — 다시 빌드도 함께.
if key in _CACHE and not await asyncio.to_thread(bundle_stale, BUNDLE):
return _CACHE[key]
output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, {"detail": detail})
output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, payload)
except Exception:
logger.warning("B07 덧그림 범위 계산 실패 — 서버 그림만으로 배치", exc_info=True)
return {}
@@ -0,0 +1,191 @@
"""B07 도면 축척(485 · 요구 280-5) — 프로젝트마다 `drawing_scales.json` 읽기 · 쓰기 · GET/PUT.
엔진은 `Engine_Template.drawing_scale(kind)` 로 읽는다(문맥 변수 · 엔진 서명 그대로).
값이 바뀐 종류의 저장본(확정)은 푼다 — 축척 다른 저장본은 쓸모없음 · 종단 가로가 바뀌면
장 나눔 · 장 id 가 바뀌므로 그 종류 저장본을 모두 푼다. 화면은 `saved` 장 수로 먼저 묻는다.
"""
import asyncio
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Engine_Sections import parse_cross_key
from B06_Section.B06_Section_Repository import merge_cross_section_design_by_round
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
DRAWING_SCALE_DEFAULTS,
drawing_scales_path,
read_drawing_scales,
)
from B07_DesignDetail.B07_DesignDetail_Router import _CROSS_ID, _confirmed_source
from B07_DesignDetail.B07_DesignDetail_Router_Support import _cross_sheet_plan
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_read_json,
_read_manifest,
_write_manifest,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_workflow_state import start_stage
from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
# 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음.
SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = {
"longitudinal": {"x": (100, 10000), "y": (10, 2000)},
"cross": {"x": (10, 1000), "y": (10, 1000)},
"mass_haul": {"x": (100, 20000), "y": (1000, 1000000)},
"development": {"x": (10, 10000), "y": (10, 10000)},
}
_KIND_LABEL = {
"longitudinal": "종단도",
"cross": "횡단도",
"mass_haul": "토적도",
"development": "전개도",
}
def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]:
"""{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본(자동 칸은 자동)."""
if not isinstance(scales, dict):
raise ValueError("축척은 {종류: {x, y}} 꼴로 보내 주세요.")
checked: dict[str, dict[str, int | None]] = {}
for kind, axes in scales.items():
if kind not in SCALE_RANGES:
raise ValueError(f"축척을 바꿀 수 없는 도면입니다: {kind}")
if not isinstance(axes, dict):
raise ValueError(f"{_KIND_LABEL[kind]} 축척은 {{x, y}} 꼴로 보내 주세요.")
row: dict[str, int | None] = {}
for axis, (low, high) in SCALE_RANGES[kind].items():
value = axes.get(axis)
if value is not None and (
isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high
):
name = "X(가로)" if axis == "x" else "Y(세로)"
raise ValueError(
f"{_KIND_LABEL[kind]} {name} 축척은 1/{low:,}~1/{high:,} 사이 정수로 적으세요."
)
row[axis] = value
checked[kind] = row
return checked
def saved_counts(project_root: Path) -> dict[str, int]:
"""종류마다 저장본(확정) 장 수."""
counts = dict.fromkeys(SCALE_RANGES, 0)
for entry in _read_manifest(project_root)["drawings"].values():
if entry.get("confirmed") and entry.get("kind") in counts:
counts[entry["kind"]] += 1
return counts
def scales_payload(project_root: Path) -> dict[str, Any]:
return {
"scales": read_drawing_scales(project_root),
"defaults": DRAWING_SCALE_DEFAULTS,
"ranges": {
kind: {axis: list(bounds) for axis, bounds in axes.items()}
for kind, axes in SCALE_RANGES.items()
},
"saved": saved_counts(project_root),
}
def write_drawing_scales(
project_root: Path, longitudinal_path: Path, scales: Any
) -> tuple[dict[str, int], list[int | float]]:
"""보낸 종류만 바꿔 쓰고 값이 바뀐 종류의 저장본을 푼다 — (풀린 장 수, 풀린 횡단 측점)."""
checked = checked_scales(scales)
before = read_drawing_scales(project_root)
path = drawing_scales_path(project_root)
try:
stored = _read_json(path) if path.is_file() else {}
except ValueError:
stored = {} # 깨진 파일 = 기본 — 새 값으로 덮음
atomic_write_json(path, {**stored, **checked})
after = read_drawing_scales(project_root)
changed = {kind for kind in checked if after[kind] != before[kind]}
invalidated: dict[str, int] = {}
stale_ids: list[str] = []
manifest = _read_manifest(project_root)
for drawing_id, entry in manifest["drawings"].items():
kind = entry.get("kind")
if entry.get("confirmed") and kind in changed:
entry["confirmed"] = False
invalidated[kind] = invalidated.get(kind, 0) + 1
if kind == "cross":
stale_ids.append(drawing_id)
if invalidated:
_write_manifest(project_root, manifest)
# 풀린 횡단 장 · 측점 도면에 담긴 측점 — 설계 상태를 잠정으로 되돌릴 몫(배치 바꿈과 같은 꼴)
stale: list[int | float] = []
sheets: dict[str, list[int | float]] | None = None
for drawing_id in stale_ids:
if match := _CROSS_ID.fullmatch(drawing_id):
stale.append(parse_cross_key(match.group(1)))
continue
if sheets is None: # 장 계획은 장이 풀릴 때만 셈
plan = _cross_sheet_plan(project_root, longitudinal_path)
sheets = {sheet["id"]: sheet["chainages"] for sheet in plan}
stale.extend(sheets.get(drawing_id, []))
return invalidated, stale
class DrawingScalesRequest(BaseModel):
"""바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본(자동)."""
scales: dict[str, Any] = Field(default_factory=dict)
@router.get("/{project_id}/drawing-scales")
async def get_drawing_scales(project_id: UUID) -> Any:
"""도면 축척 — 파일 없으면 기본 · 종류마다 저장본 장 수(`saved`)."""
try:
_route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id)
return await asyncio.to_thread(scales_payload, project_root)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
@router.put("/{project_id}/drawing-scales")
async def put_drawing_scales(project_id: UUID, request: DrawingScalesRequest) -> Any:
"""축척을 쓴다 — 값이 바뀐 종류의 저장본은 풀고 횡단이면 담긴 측점 설계를 잠정으로."""
try:
route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id)
invalidated, stale = await asyncio.to_thread(
write_drawing_scales, project_root, longitudinal_path, request.scales
)
if invalidated:
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage=chainage,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 4)
await connection.commit()
except Exception:
await connection.rollback()
raise
payload = await asyncio.to_thread(scales_payload, project_root)
return {**payload, "invalidated": invalidated}
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
@@ -8,7 +8,7 @@
* 앞으로 B06에 구조물·치수·글자가 붙으면 여기 손대지 않아도 도면에 따라온다.
*
* 자리 맞추기는 서버가 도면에 실어 보내는 `cross_placements`가 정한다:
* x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy
* x_mm = offset*mm_per_m_x + ox, y_mm = (elev - dy)*mm_per_m_y + oy (없으면 mm_per_m · 485)
* SVG는 y가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다.
* ========================================================================== */
@@ -20,8 +20,9 @@ export function buildDevelopmentSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
collapsed = true,
onSettings?: () => void,
): HTMLElement {
const section = createPanelCard(`전개도 ${items.length}`, collapsed);
const section = createPanelCard(`전개도 ${items.length}`, collapsed, onSettings);
section.classList.add("b07-drawing-group");
section.dataset.kind = "development";
if (items.length === 0) {
+124 -218
View File
@@ -12,17 +12,11 @@
import "./B07_DesignDetail_UI_Style.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
buildCrossLayoutControls,
buildDesignInfoPanel,
buildDrawingSidePanel,
foldedGroups,
@@ -36,14 +30,13 @@ import {
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmDesignDrawing,
completeDesignStage,
exportDrawing,
fetchCrossLayout,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
invalidateDesignDrawing,
resetFrameTemplate,
saveCrossLayout,
saveFrameTemplate,
@@ -55,6 +48,8 @@ import {
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
import { hasRedrawable, redrawFromSource, saveEditedDrawings } from "./B07_DesignDetail_UI_Save";
import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
@@ -67,7 +62,7 @@ interface DesignMeta {
drawingId: string;
title: string;
info: string;
/** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */
/** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */
confirmed: boolean;
quantityTable: QuantityTable | null;
hasPrev: boolean;
@@ -116,9 +111,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
onChanged: (dirty) => {
if (frameEditor.isEditing()) return;
cadDirty = dirty;
@@ -135,18 +128,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
void fillCrossLayout(drawing);
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
@@ -159,61 +147,28 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
};
/** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */
const crossLayoutSlot = document.createElement("div");
crossLayoutSlot.className = "b07-cross-layout-slot";
// 배치 칸을 못 받아도 안내선 단추는 남음 · 받으면 같은 줄 끝에 붙음
const guideOnly = document.createElement("div");
guideOnly.className = "b07-cross-layout";
guideOnly.append(guideToggle);
crossLayoutSlot.append(guideOnly);
let crossLayoutRequest: Promise<CrossLayout> | undefined;
const crossLayout = (): Promise<CrossLayout> =>
(crossLayoutRequest ??= fetchCrossLayout(projectId as string)).catch((error) => {
crossLayoutRequest = undefined; // 실패는 남기지 않음 — 다음 ⚙ 에 다시 받음
throw error;
});
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */
const fillCrossLayout = async (drawing: DesignDrawingItem) => {
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (!projectId) return;
let layout: CrossLayout;
/** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */
const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => {
if (!projectId || !mayDiscardEdits()) return;
showLoadingOverlay();
try {
layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId));
} catch {
crossLayoutRequest = undefined;
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
await saveCrossLayout(projectId, next);
reopenCrossSheet = sheet || undefined;
reopenDrawingId = sheet ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
if (currentDrawing?.id !== drawing.id) return;
const number = isCrossSheet(drawing)
? drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1
: 0;
const save = async (next: CrossLayout) => {
if (!mayDiscardEdits()) return;
showLoadingOverlay();
try {
await saveCrossLayout(projectId, next);
reopenCrossSheet = number || undefined;
reopenDrawingId = number ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
};
const controls = buildCrossLayoutControls(
(number && layout.sheets[String(number)]) || layout.default,
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => void save({ ...layout, table_scale: scale }),
},
},
);
controls.append(guideToggle);
crossLayoutSlot.replaceChildren(controls);
};
/**
@@ -223,28 +178,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
*/
let cadDirty = false;
/**
* 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정).
* 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다.
*/
const confirmButton = createButton({
label: "현재 도면 확정",
variant: "filled",
onClick: () => {
if (currentConfirmed) void reopenCurrentDrawing();
else void confirmCurrentDrawing();
},
});
confirmButton.disabled = true;
const applyConfirmButtonState = (): void => {
confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정";
confirmButton.title = currentConfirmed
? "확정을 풀고 이 도면을 고칩니다."
: "이 도면을 확정하고 저장합니다.";
confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank";
};
const findButton = (drawingId: string) =>
drawingListEl?.querySelector<HTMLButtonElement>(
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
@@ -265,7 +198,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
drawingId: drawing.id,
title: drawing.label,
info: drawing.kind === "cross" ? drawing.label : "",
confirmed: response.confirmed,
confirmed: false, // 잠금 없음(요구 281)
quantityTable: response.quantity_table ?? null,
hasPrev: index > 0,
hasNext: index < drawings.length - 1,
@@ -328,7 +261,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
"남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.",
"남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.",
);
};
@@ -347,9 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
applyConfirmButtonState();
updateInfoPanel(drawing, response);
// 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만)
const guide = response.guide ?? null;
@@ -360,17 +291,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
if (response.drawing.stale_layout) {
showToast(
"옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"옛 배치로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_scale) {
showToast(
"옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.",
"옛 표 배율로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 지금 배율로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_layers) {
showToast(
"측점 도면층 앞에 확정된 장입니다. [수정]을 누르면 측점마다 층(횡단 No.)으로 다시 만듭니다.",
"측점 도면층 앞에 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 측점마다 층(횡단 No.)으로 다시 만듭니다.",
"info",
);
}
@@ -418,85 +349,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
async function confirmCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing) return;
showLoadingOverlay();
try {
const saved = await requestCadDrawing();
const result = await confirmDesignDrawing(
projectId,
currentDrawing.id,
saved.drawing,
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
currentConfirmed = true;
currentDrawing.confirmed = true;
cadDirty = false; // 저장했다
const savedTab = tabs.active();
if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨
applyConfirmButtonState();
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다.
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
currentDrawing.label,
result.design ?? null,
isCrossSheet(currentDrawing) ? "sheet" : "station",
),
);
}
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
}
} catch (error) {
showToast(
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
/**
* [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다
* (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나
* 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
*/
async function reopenCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing || !currentConfirmed) return;
showLoadingOverlay();
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
drawingCache.delete(currentDrawing.id);
currentConfirmed = false;
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "false";
applyConfirmButtonState();
// 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다.
await loadDrawing(currentDrawing, currentIndex);
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
} catch (error) {
showToast(
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
const store = {
projectId: projectId as string,
drawings,
tabs,
current: () => currentDrawing,
cadDirty: () => cadDirty,
setCadClean: () => void (cadDirty = false),
requestCad: requestCadDrawing,
isFrameEditing: () => frameEditor.isEditing(),
forget: (id: string) => void drawingCache.delete(id),
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
};
/**
* 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시).
@@ -545,49 +410,98 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
/** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */
const onPlanClosed = (changed: boolean, openId?: string): void => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
};
// 묶음 ⚙(280-5) — 평면도 = 평면도 관리 · 횡단도 = 장 배치 · 안내선 · 머리 [원본에서 다시 그리기]
const openSettings = (kind: DesignDrawingItem["kind"]): void =>
openDrawingSettings(kind, {
drawings,
current: () => currentDrawing,
canRedraw: () => hasRedrawable(store),
redraw: () => redrawFromSource(store),
plan: projectId ? { projectId, onClosed: onPlanClosed } : undefined,
cross: {
layout: crossLayout,
save: (next, sheet) => void saveCrossLayoutAndRebuild(next, sheet),
guideToggle,
},
});
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
devBypass,
projectId
? {
projectId,
// [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onClosed: (changed, openId) => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
},
}
: undefined,
crossLayoutSlot,
projectId ? openSettings : undefined,
folded,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
const actions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
confirmActions.className = "b07-drawing-actions ui-sidebar-actions";
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.dataset.helpRow = "";
confirmButtonRow.append(frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow);
actions.className = "b07-drawing-actions ui-sidebar-actions";
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움).
const actionRow = document.createElement("div");
actionRow.className = "b07-drawing-actions__row";
actionRow.dataset.helpRow = "";
actionRow.append(frameEditor.button);
actions.append(frameEditor.banner, actionRow);
drawingPanel.append(infoPanelHost, confirmActions);
drawingPanel.append(infoPanelHost, actions);
const cadStack = document.createElement("div");
cadStack.className = "b07-cad-stack";
cadStack.append(tabs.element, cad.element);
// 탭 줄 겹판(280-6) — CAD 가 리본 [출력] 오른쪽 빈 자리를 `.cad-host` 에 `--cad-tab-slot-left/right`
// (px · CSS left · right 꼴)로 적으면 그 자리에 겹침 · 값이 없으면 CAD 위 지금 자리.
const placeTabs = (): void => {
const slotted = cad.element.style.getPropertyValue("--cad-tab-slot-left") !== "";
if (slotted === (tabs.element.parentElement === cad.element)) return;
if (slotted) cad.element.append(tabs.element);
else cadStack.prepend(tabs.element);
};
new MutationObserver(placeTabs).observe(cad.element, {
attributes: true,
attributeFilter: ["style"],
});
placeTabs();
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
let moving = false;
const moveToStage = async (stepIndex: number): Promise<void> => {
if (!projectId || moving) return;
moving = true;
try {
if (!(await saveEditedDrawings(store))) return;
if (stepIndex > 4 && drawings.length > 0) {
try {
await completeDesignStage(projectId);
} catch (error) {
showToast(
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
"error",
);
return;
}
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
} finally {
moving = false;
}
};
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
steps: workflowSteps(),
@@ -595,19 +509,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
leftPanel: drawingPanel,
mainContent: cadStack,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
// B08 = 옮길 때 단계 4 를 완료하므로 운영에서 막힌 상태여도 켬(281) — 공용 판은 이 단계를 켬
currentStage: 5,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (!projectId) return;
// ⚠ 개발에서는 막지 않는다 (2026-09-09 사용자 지시) — B08·B09 를 확정 전에 봐야
// 화면 검증이 되고, 그 우회 단추가 **B08 안에** 있어 여기서 막히면 닿을 길이 없다.
// 운영에서는 그대로 막는다.
if (stepIndex > 5 && !allDrawingsConfirmed && !import.meta.env.DEV) {
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
return;
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
onStepClick: (stepIndex) => void moveToStage(stepIndex),
});
layout.root.classList.add("b07-design-layout");
root.replaceChildren(layout.root);
+42 -15
View File
@@ -12,7 +12,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -60,9 +59,8 @@ export function buildDrawingSidePanel(
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
plan?: PlanManageControls,
/** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */
crossTools?: HTMLElement,
/** 묶음 ⚙(280-5) — 카드 제목 · 한 장 단추 안 오른쪽 · 표지 · 빈 도각은 없음. */
onSettings?: (kind: DesignDrawingItem["kind"]) => void,
/** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */
folded = new Map<string, boolean>(),
): HTMLDivElement {
@@ -107,24 +105,26 @@ export function buildDrawingSidePanel(
button.addEventListener("click", () => onSelect(drawing));
return button;
};
const settingsOf = (kind: DesignDrawingItem["kind"]) =>
onSettings && kind !== "cover" && kind !== "blank" ? () => onSettings(kind) : undefined;
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(
buildPlanSection(
plans,
drawingButton,
plan ? () => openPlanManager(plan) : undefined,
isFolded("plan"),
),
);
panel.append(buildPlanSection(plans, drawingButton, settingsOf("plan"), isFolded("plan")));
continue;
}
if (group.kind === "development") {
const sheets = drawings.filter((item) => item.kind === "development");
panel.append(buildDevelopmentSection(sheets, drawingButton, isFolded("development")));
panel.append(
buildDevelopmentSection(
sheets,
drawingButton,
isFolded("development"),
settingsOf("development"),
),
);
continue;
}
const items = group.kind
@@ -158,11 +158,17 @@ export function buildDrawingSidePanel(
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
const settings = settingsOf(drawing.kind);
if (settings) button.append(buttonGear(settings));
panel.append(button);
continue;
}
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
const section = createPanelCard(`${group.label} ${items.length}`, isFolded(group.kind ?? ""));
const section = createPanelCard(
`${group.label} ${items.length}`,
isFolded(group.kind ?? ""),
group.kind && settingsOf(group.kind),
);
section.classList.add("b07-drawing-group");
if (group.kind) section.dataset.kind = group.kind;
if (cross) {
@@ -180,13 +186,34 @@ export function buildDrawingSidePanel(
empty.textContent = "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.";
section.append(empty);
}
if (cross && crossTools) section.append(crossTools);
panel.append(section);
}
attachCollapsible(panel);
return panel;
}
/** 한 장 단추 안 오른쪽 ⚙(280-5) — 단추 안 단추 금지라 span · 누름 · Enter 가 도면 열기로 안 올라감. */
function buttonGear(onSettings: () => void): HTMLElement {
const gear = document.createElement("span");
gear.className = "b07-drawing-button__settings";
gear.setAttribute("role", "button");
gear.tabIndex = 0;
gear.textContent = "⚙";
gear.title = "설정";
gear.setAttribute("aria-label", "설정");
gear.addEventListener("click", (event) => {
event.stopPropagation();
onSettings();
});
gear.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onSettings();
});
return gear;
}
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping",
@@ -252,7 +252,7 @@ export function groupPlanSheets(
});
}
/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추, 아래에 [평면도 관리](130-1). */
/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추 · 제목 ⚙ = 평면도 관리 모달(130-1 · 280-5). */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
@@ -260,7 +260,7 @@ export function buildPlanSection(
collapsed = true,
): HTMLElement {
// 컨테이너 = 좌측 패널 공용 카드(269)
const section = createPanelCard(`평면도 ${items.length}`, collapsed);
const section = createPanelCard(`평면도 ${items.length}`, collapsed, onManage);
section.classList.add("b07-drawing-group", "b07-plan");
section.dataset.kind = "plan";
@@ -273,13 +273,5 @@ export function buildPlanSection(
block.append(...buttons);
section.append(block);
}
if (onManage) {
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리");
manage.type = "button";
manage.addEventListener("click", onManage);
section.append(manage);
}
return section;
}
@@ -34,6 +34,8 @@ export interface PlanManageControls {
projectId: string;
/** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
onClosed: (changed: boolean, openId?: string) => void;
/** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */
actions?: HTMLElement[];
}
function el<K extends keyof HTMLElementTagNameMap>(
@@ -54,7 +56,7 @@ function button(className: string, text: string, onClick: () => void): HTMLButto
return node;
}
export function openPlanManager(controls: PlanManageControls): void {
export function openPlanManager(controls: PlanManageControls): { close: () => void } {
const { projectId } = controls;
let changed = false;
let closed = false;
@@ -240,6 +242,7 @@ export function openPlanManager(controls: PlanManageControls): void {
title: "평면도 관리",
closeLabel: "닫기",
dialogClass: "b07-plan-manage",
actions: controls.actions,
beforeClose: () => {
finish();
return true;
@@ -252,4 +255,5 @@ export function openPlanManager(controls: PlanManageControls): void {
void refresh();
},
});
return { close: () => finish() };
}
@@ -0,0 +1,147 @@
/* =============================================================================
* B07_DesignDetail_UI_Save.ts
* 상세 설계 저장 · 원본 되돌림 묶음(요구 280-5 · 280-6 · 281) — `_UI_Page.ts` 가 700줄에 닿아 뗌.
*
* 저장 = 페이지를 옮길 때만([<] [>] · ☰ · 진행 단계 판 — `onStepClick` 한 길).
* 고친 탭(●) + 지금 탭을 `confirm` 길로 · 안 고친 도면은 보내지 않음(늘 원본에서 새로 그림).
* 잠금 없음 — 저장본도 그대로 고침 · 다시 옮길 때 다시 저장.
* 원본 되돌림 = ⚙ 모달 [원본에서 다시 그리기] — 저장본이면 `invalidate` · 고친 것 버리고 다시 실음.
* ========================================================================== */
import {
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import type { CadSaveResult } from "@ui/cad_host/cad_host";
import type { CadTabs } from "@ui/cad_host/cad_host_tabs";
import {
confirmDesignDrawing,
invalidateDesignDrawing,
type CadDrawing,
type DesignDrawingItem,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
/** 화면 조립이 쥔 상태를 읽고 쓰는 길 — 이 묶음은 상태를 갖지 않음. */
export interface DrawingStore {
projectId: string;
drawings: DesignDrawingItem[];
tabs: CadTabs<CadDrawing>;
current: () => DesignDrawingItem | undefined;
/** CAD 에 저장 안 한 편집이 있나 · 지움 */
cadDirty: () => boolean;
setCadClean: () => void;
requestCad: () => Promise<CadSaveResult<CadDrawing, QuantityTable>>;
isFrameEditing: () => boolean;
/** 도면 캐시에서 뺌 — 다음 열기에 서버에서 다시 받음 */
forget: (id: string) => void;
/** 그 도면을 서버에서 다시 받아 실음 */
reload: (drawing: DesignDrawingItem) => Promise<void>;
}
/** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */
function markButton(id: string, saved: boolean): void {
document
.querySelectorAll<HTMLElement>(`.b07-drawing-button[data-drawing-id="${id}"]`)
.forEach((button) => (button.dataset.confirmed = String(saved)));
}
/**
* 고친 도면을 저장 — 지금 탭(CAD 에서 받음 · 횡단은 수량표 같이) + 다른 고친 탭(받아 둔 편집본).
* 고친 것 없으면 요청 없이 참 · 하나라도 실패하면 알림 뒤 거짓(부르는 쪽은 머묾).
*/
export async function saveEditedDrawings(store: DrawingStore): Promise<boolean> {
if (store.isFrameEditing()) {
showToast("도각 편집을 마친 뒤 옮기세요.", "warning");
return false;
}
const current = store.current();
const active = store.tabs.active();
const editable = (item: DesignDrawingItem | undefined): item is DesignDrawingItem =>
!!item && item.kind !== "blank";
const currentEdited = editable(current) && (store.cadDirty() || !!active?.dirty);
const others = store.tabs
.tabs()
.filter((tab) => tab !== active && tab.dirty && tab.drawing)
.map((tab) => ({ tab, item: store.drawings.find((item) => item.id === tab.id) }))
.filter(({ item }) => editable(item));
if (!currentEdited && others.length === 0) return true;
const save = async (
item: DesignDrawingItem,
drawing: CadDrawing,
table: QuantityTable | null,
) => {
await confirmDesignDrawing(store.projectId, item.id, drawing, table);
item.confirmed = true;
markButton(item.id, true);
store.forget(item.id); // 저장본이 정본 — 다음 열기는 서버에서
};
showLoadingOverlay();
try {
if (currentEdited && current) {
const saved = await store.requestCad();
await save(
current,
saved.drawing,
current.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
store.setCadClean();
if (active) store.tabs.markClean(active);
}
for (const { tab, item } of others) {
// 횡단 수량표는 서버가 그림의 표 글에서 다시 뽑음(받아 둔 편집본엔 수량표 칸이 없음)
await save(item as DesignDrawingItem, tab.drawing as CadDrawing, null);
store.tabs.markClean(tab);
}
return true;
} catch (error) {
showToast(
`${error instanceof Error ? error.message : "도면을 저장하지 못했습니다."} — 이 화면에 머묾`,
"error",
);
return false;
} finally {
hideLoadingOverlay();
}
}
/** 지금 도면에 버릴 것(저장본 · 고친 것)이 있나 — ⚙ [원본에서 다시 그리기] 켬. */
export function hasRedrawable(store: DrawingStore): boolean {
const current = store.current();
if (!current || current.kind === "blank") return false;
return current.confirmed || store.cadDirty() || !!store.tabs.active()?.dirty;
}
/** ⚙ [원본에서 다시 그리기] — 물은 뒤 저장본 풂(`invalidate`) · 고친 것 버림 · 다시 실음. 해냈으면 참. */
export async function redrawFromSource(store: DrawingStore): Promise<boolean> {
const current = store.current();
if (!current || !hasRedrawable(store)) return false;
const ok = await showConfirmDialog(
`「${current.label}」 의 저장본 · 고친 것을 버리고 원본에서 다시 그릴까요?`,
"다시 그리기",
);
if (!ok) return false;
showLoadingOverlay();
try {
if (current.confirmed) {
await invalidateDesignDrawing(store.projectId, current.id);
current.confirmed = false;
markButton(current.id, false);
}
store.forget(current.id);
store.setCadClean();
const active = store.tabs.active();
if (active) store.tabs.markClean(active);
await store.reload(current);
showToast("원본에서 다시 그렸습니다.", "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : "원본에서 다시 그리지 못했습니다.", "error");
return false;
} finally {
hideLoadingOverlay();
}
}
@@ -0,0 +1,127 @@
/* =============================================================================
* B07_DesignDetail_UI_Settings.ts
* 좌측 도면 묶음 ⚙ 설정 모달(요구 280-5) — 공용 `openModal` · 머리 [원본에서 다시 그리기](281).
*
* 평면도 = 평면도 관리 모달 그대로(`openPlanManager`)
* 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`)
* [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CrossLayout, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import {
buildCrossLayoutControls,
DRAWING_GROUPS,
isCrossSheet,
} from "./B07_DesignDetail_UI_Panels";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale";
type Kind = DesignDrawingItem["kind"];
export interface DrawingSettingsDeps {
drawings: DesignDrawingItem[];
current: () => DesignDrawingItem | undefined;
/** 지금 도면에 버릴 것(저장본 · 고친 것)이 있나 */
canRedraw: () => boolean;
/** 원본에서 다시 그리기 — 해냈으면 참(모달 닫음) */
redraw: () => Promise<boolean>;
plan?: PlanManageControls;
cross: {
layout: () => Promise<CrossLayout>;
/** 배치 저장 — 모달은 닫힌 뒤 · `sheet` = 지금 장 번호(장 아니면 0) */
save: (next: CrossLayout, sheet: number) => void;
guideToggle: HTMLButtonElement;
};
}
/** ⚙ 묶음에 드는 도면 종류 — 횡단도 카드엔 표준 횡단면도 줄이 같이 있음. */
const memberOf = (kind: Kind): Kind[] => (kind === "cross" ? ["cross", "cross_standard"] : [kind]);
const note = (text: string): HTMLElement => {
const line = document.createElement("p");
line.className = "b07-settings__note";
line.textContent = text;
return line;
};
/** 묶음 `kind` 의 ⚙ 모달을 엶. */
export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void {
const current = deps.current();
const mine = !!current && memberOf(kind).includes(current.kind);
let closeModal = (): void => undefined;
const redraw = createButton({
label: "원본에서 다시 그리기",
variant: "ghost",
disabled: !(mine && deps.canRedraw()),
onClick: () => {
void deps.redraw().then((done) => done && closeModal());
},
});
redraw.title = !mine
? "이 묶음 도면을 연 뒤 쓸 수 있음"
: redraw.disabled
? "이미 원본 그대로"
: "저장본 · 고친 것을 버리고 B06 원본에서 새로 그림";
redraw.classList.add("b07-settings__redraw");
if (kind === "plan" && deps.plan) {
const manager = openPlanManager({ ...deps.plan, actions: [redraw] });
closeModal = manager.close;
return;
}
const label = DRAWING_GROUPS.find((group) => group.kind === kind)?.label ?? "도면";
openModal({
title: `${label} 설정`,
closeLabel: "닫기",
dialogClass: "b07-settings",
actions: [redraw],
mount: (body, close) => {
closeModal = close;
if (mine) body.append(note(`지금 도면: ${current?.label}`));
if (kind === "structure") body.append(note("M02 그림 — 축척 없음"));
mountDrawingScale(body, kind, deps, close);
if (kind === "cross") mountCross(body, deps, close);
},
});
}
/** 횡단 모달 몸 — 배치 칸은 받은 뒤 · 안내선 단추는 먼저(배치를 못 받아도 남음). */
function mountCross(body: HTMLElement, deps: DrawingSettingsDeps, close: () => void): void {
const current = deps.current();
const sheet =
current && isCrossSheet(current)
? deps.drawings.filter(isCrossSheet).findIndex((item) => item.id === current.id) + 1
: 0;
body.classList.add("ui-panel-form"); // 칸 · 단추 꼴 = 좌측 패널 때와 같음(269)
const holder = document.createElement("div");
holder.className = "b07-cross-layout";
holder.append(deps.cross.guideToggle);
body.append(holder);
void deps.cross
.layout()
.then((layout) => {
const apply = (next: CrossLayout): void => {
close();
deps.cross.save(next, sheet);
};
const controls = buildCrossLayoutControls(
(sheet && layout.sheets[String(sheet)]) || layout.default,
sheet
? (grid) => apply({ ...layout, sheets: { ...layout.sheets, [String(sheet)]: grid } })
: null,
(grid) => apply({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => apply({ ...layout, table_scale: scale }),
},
},
);
controls.append(deps.cross.guideToggle);
holder.replaceWith(controls);
})
.catch(() => holder.prepend(note("배치를 받지 못했습니다.")));
}
@@ -0,0 +1,157 @@
/* =============================================================================
* B07_DesignDetail_UI_Settings_Scale.ts
* ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만.
*
* 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」
* [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림)
* [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장]
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
type DrawingScaleAxes,
type DrawingScaleKind,
type DrawingScales,
fetchDrawingScales,
saveDrawingScales,
} from "./B07_DesignDetail_Api_Fetch";
import type { DrawingSettingsDeps } from "./B07_DesignDetail_UI_Settings";
import { readScaleAxis, scaleToSend } from "./logic/B07_DesignDetail_Scale_Check";
const LABEL: Record<DrawingScaleKind, string> = {
longitudinal: "종단도",
cross: "횡단도",
mass_haul: "토적도",
development: "전개도",
};
const AXES = [
["x", "X(가로) 1/"],
["y", "Y(세로) 1/"],
] as const;
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */
export function mountDrawingScale(
body: HTMLElement,
kind: string,
deps: DrawingSettingsDeps,
close: () => void,
): void {
if (!isScaleKind(kind) || !deps.plan) return;
const { projectId, onClosed } = deps.plan;
const section = document.createElement("div");
section.className = "b07-scale";
section.textContent = "축척 받는 중…";
body.append(section);
void fetchDrawingScales(projectId)
.then((data) => {
section.textContent = "";
buildScaleFields(section, kind, data, async (next) => {
const saved = data.saved[kind] ?? 0;
const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`;
if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return;
showLoadingOverlay();
try {
await saveDrawingScales(projectId, { [kind]: next });
close();
showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success");
onClosed(true, deps.current()?.id); // 목록 · 도면을 서버에서 다시 받아 다시 그림
} catch (error) {
showToast(
error instanceof Error ? error.message : "축척을 저장하지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
});
})
.catch(() => {
section.textContent = "축척을 받지 못했습니다.";
});
}
function buildScaleFields(
section: HTMLElement,
kind: DrawingScaleKind,
data: DrawingScales,
save: (next: DrawingScaleAxes) => Promise<void>,
): void {
const fields = AXES.map(([axis, label]) => {
const fallback = data.defaults[kind][axis];
const range = data.ranges[kind][axis];
const [min, max] = range;
const field = createInputField({ label, type: "number", compact: true, min, max });
field.root.classList.add("b07-scale__field");
const auto = fallback === null ? createCheckbox("자동") : undefined;
if (auto) field.root.append(auto.root);
const set = (value: number | null): void => {
field.input.value = value === null ? "" : String(value);
if (auto) auto.input.checked = value === null;
field.input.disabled = value === null && !!auto;
};
set(data.scales[kind][axis]);
const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range);
return { axis, field, auto, fallback, set, read };
});
const saveButton = createButton({
label: "축척 저장",
onClick: () => {
const [x, y] = fields.map((f) => f.read()) as [number | null, number | null];
const now = data.scales[kind];
if (x === now.x && y === now.y) {
showToast("바뀐 축척이 없습니다.", "info");
return;
}
void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) });
},
});
const check = (): void => {
let bad = false;
for (const f of fields) {
const result = f.read();
f.field.setError(typeof result === "string" ? result : undefined);
bad ||= typeof result === "string";
}
saveButton.disabled = bad;
};
for (const f of fields) {
f.field.input.addEventListener("input", check);
f.auto?.input.addEventListener("change", () => {
if (f.auto!.input.checked) f.set(null);
else f.field.input.disabled = false; // 빈 칸 — 숫자를 적어야 저장
check();
});
}
const reset = createButton({
label: "기본값",
variant: "ghost",
onClick: () => {
fields.forEach((f) => f.set(f.fallback));
check();
},
});
reset.title = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀";
const title = document.createElement("p");
title.className = "b07-settings__note";
title.textContent =
kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`;
const row = document.createElement("div");
row.className = "b07-scale__row";
row.append(...fields.map((f) => f.field.root));
const buttons = document.createElement("div");
buttons.className = "b07-scale__actions";
buttons.append(reset, saveButton);
section.append(title, row, buttons);
}
+79 -11
View File
@@ -89,6 +89,28 @@
text-overflow: ellipsis;
}
/* 한 장 단추 ⚙(280-5) — 단추 안 오른쪽 · 이름은 가운데 그대로 */
.b07-drawing-button:has(.b07-drawing-button__settings) {
position: relative;
padding-right: 22px;
padding-left: 22px;
}
.b07-drawing-button__settings {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
color: var(--color-text-muted);
line-height: 1;
cursor: pointer;
}
.b07-drawing-button__settings:hover,
.b07-drawing-button__settings:focus-visible {
color: var(--color-primary);
}
.b07-drawing-button:hover,
.b07-drawing-button[data-active="true"] {
border-color: var(--color-border);
@@ -200,17 +222,8 @@
color: var(--color-text-muted);
}
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */
.b07-cross-layout-slot {
grid-column: 1 / -1;
}
.b07-cross-layout-slot:empty {
display: none;
}
/* 횡단 장 배치(153-1 · 212-3 · 280-5 ⚙ 횡단 모달) — 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비. */
.b07-cross-layout {
display: flex;
flex-direction: column;
@@ -459,3 +472,58 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
flex: 1 1 0;
height: auto;
}
/* 탭 줄 겹판(280-6) — CAD 리본 탭 줄 빈 자리(CAD 가 `.cad-host` 에 적은 px) · 리본 탭 줄 26 px 안 */
.b07-cad-stack .cad-host > .cad-tabs {
position: absolute;
z-index: 3;
top: 0;
right: var(--cad-tab-slot-right, 0px);
left: var(--cad-tab-slot-left, 0px);
height: 26px;
padding: 2px 4px 0;
border-bottom: none;
background: transparent;
}
.b07-cad-stack .cad-host > .cad-tabs .cad-tabs__tab {
padding-block: 2px;
}
/* ⚙ 설정 모달(280-5) — 안내 한 줄 · 횡단 배치 칸 */
.b07-settings__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
font-size: 13px;
}
.b07-settings .b07-cross-layout {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 280px;
}
/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */
.b07-scale {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 280px;
margin-bottom: var(--spacing-8);
}
.b07-scale__row {
display: flex;
gap: var(--spacing-8);
}
.b07-scale__field {
flex: 1;
}
.b07-scale__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
@@ -0,0 +1,24 @@
/* =============================================================================
* B07_DesignDetail_Scale_Check.ts
* ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글.
* 서버 `checked_scales`(`_Router_Support_Scale.py`)와 같은 규칙 — 범위는 서버가 내려 줌.
* ========================================================================== */
/** 칸 하나 읽기 — 숫자 · null(자동) · 까닭 글(틀림). */
export function readScaleAxis(
raw: string,
auto: boolean,
[low, high]: [number, number],
): number | null | string {
if (auto) return null;
const text = raw.trim().replace(/,/g, "");
const value = Number(text);
if (text === "" || !Number.isInteger(value) || value < low || value > high) {
return `1/${low.toLocaleString()}~1/${high.toLocaleString()} 사이 정수`;
}
return value;
}
/** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */
export const scaleToSend = (value: number | null, fallback: number | null): number | null =>
value === fallback ? null : value;
+22 -214
View File
@@ -3,7 +3,8 @@
@import "../../../ui_template/ui_template_theme.css";
:root {
--cad-title-height: 42px;
/* 제목줄 없음(요구 280-6) — 칸들이 이 변수로 자리 잡아 0 으로 둠 */
--cad-title-height: 0px;
/* 116px 에서는 리본 줄의 내용(85px)이 보이는 높이(81px)를 넘어 세로 스크롤바가
생겼다(2026-09-04 실측) — 모자란 4px + 여유 2px 를 더한다. */
--cad-ribbon-height: 122px;
@@ -55,11 +56,7 @@ button {
z-index: 2;
pointer-events: none;
}
.cad-host-save {
--cad-title-height: 0px;
}
.cad-bare {
--cad-title-height: 0px;
--cad-status-height: 0px;
}
.controls {
@@ -85,64 +82,6 @@ body > canvas[data-id="canvas"] {
cursor: none;
}
.cad-titlebar {
position: fixed;
inset: 0 0 auto 0;
height: var(--cad-title-height);
display: flex;
align-items: center;
gap: 24px;
padding: 0 12px;
background: var(--cad-chrome);
border-bottom: 1px solid var(--cad-line);
box-shadow: var(--shadow-sm);
}
.cad-brand {
display: flex;
align-items: baseline;
gap: 9px;
min-width: 220px;
}
.cad-brand strong {
color: var(--color-text);
font-size: 14px;
}
.cad-brand span,
.cad-file-state {
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-file-state {
display: flex;
align-items: center;
gap: 7px;
}
.cad-file-state__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-success);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 25%, transparent);
}
.cad-title-actions {
display: flex;
gap: 4px;
margin-left: auto;
}
.cad-title-actions button {
height: 28px;
min-width: 32px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
font-size: 12px;
}
.cad-title-actions button:hover {
border-color: var(--cad-line);
background: var(--cad-hover);
}
.cad-ribbon {
position: fixed;
top: var(--cad-title-height);
@@ -168,8 +107,11 @@ body > canvas[data-id="canvas"] {
padding: 0 8px;
border-bottom: 1px solid var(--cad-line);
}
.cad-ribbon-tabs__slot {
flex: 1;
min-width: 0;
}
.cad-ribbon-tabs__collapse {
margin-left: auto;
width: 24px;
height: 20px;
align-self: center;
@@ -275,14 +217,15 @@ body > canvas[data-id="canvas"] {
.cad-quick-access {
display: flex;
gap: 2px;
margin-left: 14px;
align-self: center;
}
.cad-quick-access button {
width: 26px;
height: 24px;
.cad-ribbon-tabs .cad-quick-access button {
width: 24px;
height: 20px;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
border-radius: 4px;
line-height: 1;
font-size: 13px;
}
.cad-quick-access button:hover {
@@ -334,6 +277,8 @@ body > canvas[data-id="canvas"] {
border-radius: 3px;
cursor: pointer;
}
/* 고른 것들 색이 다름(488) — 빈 견본(투명 · 점선 테두리) */
input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: transparent !important; border: 1px dashed var(--cad-text-muted); }
/* 위젯 패널 안의 작은 명령 버튼은 한 줄 높이에 맞춘다 */
.cad-ribbon-props .cad-tool[data-size="small"] {
height: 22px;
@@ -375,12 +320,15 @@ body > canvas[data-id="canvas"] {
font-size: 15px;
line-height: 1;
}
.cad-tool:hover:not(:disabled),
/* 켜짐 = 테두리 + 바탕 + 밑줄 · 스침 = 바탕만(마우스 기기만) — 켜진 단추가 늘 하나로 보이게(487) */
@media (hover: hover) {
.cad-tool:hover:not(:disabled) {
background: var(--cad-hover);
}
}
.cad-tool[data-active="true"] {
border-color: var(--cad-accent);
background: var(--cad-hover);
}
.cad-tool[data-active="true"] {
box-shadow: inset 0 -2px var(--cad-accent);
}
.cad-tool:disabled {
@@ -540,7 +488,7 @@ body > canvas[data-id="canvas"] {
flex-direction: column;
align-items: flex-end;
gap: 2px;
max-width: 360px;
white-space: nowrap;
padding: 2px 4px;
border: 0;
background: transparent;
@@ -718,153 +666,13 @@ body.cad-file-dragging::after {
font-size: 10px;
}
/* -----------------------------------------------------------------------------
* 수량 산출표 패널 (CAD 화면 하단 중심, 접이식) — 첨부 양식
* -------------------------------------------------------------------------- */
.cad-qtable {
position: fixed;
z-index: 3;
/* 명령행 위로 띄운다 — 겹치면 명령 입력칸을 클릭으로 누를 수 없다(2026-08-31).
명령행을 접으면 --cad-command-height가 0이라 예전처럼 상태막대 위에 붙는다. */
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
left: calc(var(--cad-panel-width) + (100vw - var(--cad-panel-width)) / 2);
transform: translateX(-50%);
/* 표(가장 넓은 자식) 기준 폭 고정 → 접었다 펴도 폭이 변하지 않는다 */
width: max-content;
max-width: min(1040px, calc(100vw - var(--cad-panel-width) - 24px));
border: 1px solid var(--cad-line);
border-radius: 6px;
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
color: var(--cad-text);
}
.cad-qtable__header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
}
.cad-qtable__title {
display: flex;
flex: 1;
align-items: baseline;
justify-content: center;
gap: 8px;
min-width: 0;
}
.cad-qtable__title strong {
color: var(--color-text);
font-size: 14px;
}
.cad-qtable__title span {
overflow: hidden;
color: var(--cad-text-dim);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-qtable__status {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-style: normal;
}
.cad-qtable__status[data-confirmed="true"] {
background: color-mix(in srgb, var(--color-success) 22%, transparent);
color: var(--color-success);
}
.cad-qtable__status[data-confirmed="false"] {
background: color-mix(in srgb, var(--color-warning) 22%, transparent);
color: var(--color-warning);
}
.cad-qtable__nav,
.cad-qtable__collapse {
width: 26px;
height: 24px;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
font-size: 13px;
}
.cad-qtable__nav:hover:not(:disabled),
.cad-qtable__collapse:hover {
background: var(--cad-hover);
}
.cad-qtable__nav:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.cad-qtable__body {
padding: 0 8px 8px;
overflow-x: auto;
}
/* 접힘: 표는 DOM에 남겨 폭은 유지하되 세로로만 감춘다 */
.cad-qtable[data-collapsed="true"] .cad-qtable__body {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
visibility: hidden;
}
.cad-qtable table {
margin: 0 auto;
border-collapse: collapse;
}
.cad-qtable th,
.cad-qtable td {
padding: 0;
border: 1px solid var(--cad-line);
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.cad-qtable__label {
padding: 2px 9px;
color: var(--cad-text-dim);
font-weight: 600;
white-space: nowrap;
background: var(--cad-chrome);
}
.cad-qtable__label--vertical {
width: 18px;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 1px;
}
.cad-qtable__value input {
width: 72px;
padding: 3px 4px;
border: 0;
background: transparent;
color: var(--cad-text);
font: inherit;
text-align: center;
}
.cad-qtable__value input:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
background: var(--cad-hover);
}
.cad-qtable__value--derived input {
color: var(--cad-text-muted);
background: var(--cad-chrome-sunken);
cursor: not-allowed;
}
@media (max-width: 800px) {
:root {
--cad-panel-width: 200px;
}
.cad-file-state,
.cad-statusbar__hint {
display: none;
}
.cad-brand {
min-width: auto;
}
}
/* 특성 팔레트 — 값을 바로 고치는 입력들 */
-2
View File
@@ -1,14 +1,12 @@
import './App.css';
import { ToastContainer } from 'react-toastify';
import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx';
import { QuantityPanel } from './components/QuantityPanel.tsx';
import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div className="cad-app">
<Toolbar />
<QuantityPanel />
<FramePlaceholderPanel />
<ToastContainer position="bottom-right" theme="light" />
</div>
@@ -88,9 +88,9 @@
{"id": "PLACETABLE", "label": "표 놓기", "aliases": []},
{"id": "REVCLOUD", "label": "구름형 리비전", "aliases": []},
{"id": "ANNOSCALE", "label": "주석 축척", "aliases": []},
{"id": "LAYER", "label": "도면층 특성", "aliases": ["LA"]},
{"id": "LAYCURSET", "label": "현재 도면층 설정", "aliases": []},
{"id": "LAYOFF", "label": "도면층 끄기", "aliases": []},
{"id": "LAYER", "label": "레이어 특성", "aliases": ["LA"]},
{"id": "LAYCURSET", "label": "현재 레이어 설정", "aliases": []},
{"id": "LAYOFF", "label": "레이어 끄기", "aliases": []},
{"id": "LAYON", "label": "모두 켜기", "aliases": []},
{"id": "LAYFRZ", "label": "동결", "aliases": []},
{"id": "LAYTHW", "label": "동결 해제", "aliases": []},
@@ -99,16 +99,16 @@
{"id": "LAYISO", "label": "분리", "aliases": []},
{"id": "LAYUNISO", "label": "분리 해제", "aliases": []},
{"id": "LAYERP", "label": "이전 상태", "aliases": []},
{"id": "LAYERSTATE", "label": "도면층 상태 저장", "aliases": ["LAS"]},
{"id": "LAYERSTATERESTORE", "label": "도면층 상태 복원", "aliases": []},
{"id": "LAYMCH", "label": "도면층 일치", "aliases": []},
{"id": "LAYCUR", "label": "현재 도면층으로", "aliases": []},
{"id": "LAYMRG", "label": "도면층 병합", "aliases": []},
{"id": "LAYDEL", "label": "도면층 삭제", "aliases": []},
{"id": "LAYWALK", "label": "도면층 탐색", "aliases": []},
{"id": "LAYERSTATE", "label": "레이어 상태 저장", "aliases": ["LAS"]},
{"id": "LAYERSTATERESTORE", "label": "레이어 상태 복원", "aliases": []},
{"id": "LAYMCH", "label": "레이어 일치", "aliases": []},
{"id": "LAYCUR", "label": "현재 레이어로", "aliases": []},
{"id": "LAYMRG", "label": "레이어 병합", "aliases": []},
{"id": "LAYDEL", "label": "레이어 삭제", "aliases": []},
{"id": "LAYWALK", "label": "레이어 탐색", "aliases": []},
{"id": "COLOR", "label": "색상", "aliases": ["COL"]},
{"id": "LINETYPE", "label": "선종류", "aliases": ["LT"]},
{"id": "LWEIGHT", "label": "선가중치", "aliases": ["LW"]},
{"id": "LWEIGHT", "label": "선 두께", "aliases": ["LW"]},
{"id": "PROPERTIES", "label": "특성", "aliases": ["CH", "MO", "PR"]},
{"id": "QUICKPROPERTIES", "label": "빠른 특성", "aliases": ["QP"]},
{"id": "TRANSPARENCY", "label": "투명도", "aliases": []},
@@ -136,6 +136,7 @@
{"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]},
{"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]},
{"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]},
{"id": "IMPORT", "label": "DWG 불러오기", "aliases": []},
{"id": "SELECT", "label": "선택", "aliases": []},
{"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]},
{"id": "ZOOMIN", "label": "확대", "aliases": []},
@@ -4,6 +4,7 @@ import { saveBlockFromEntities } from '../blocks/block-library';
import { setBlockLibraryVisible } from '../components/ui-state';
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
import { pickFile } from '../helpers/pick-file';
import { importDrawingFile } from '../integration/dxf-clip-paste';
import { getActiveToolActor, getSelectedEntities } from '../state';
import { Tool } from '../tools';
import { imageImportToolStateMachine } from '../tools/image-import-tool';
@@ -58,4 +59,14 @@ export const INSERT_COMMANDS: CadCommand[] = [
return '블록 라이브러리';
},
},
{
id: 'IMPORT',
label: 'DWG 불러오기',
glyph: '📂',
hint: 'DWG · DXF 파일을 지금 도면에 원좌표 그대로 더한다(골라진 채 · 되돌리기 한 번에 빠짐)',
run: () => {
pickFile('.dwg,.dxf', importDrawingFile);
return 'DWG · DXF 파일 선택';
},
},
];
@@ -25,25 +25,25 @@ import {
export const LAYER_COMMANDS: CadCommand[] = [
{
id: 'LAYER',
label: '도면층 특성',
label: '레이어 특성',
aliases: ['LA'],
glyph: '▤',
hint: '도면층을 만들고 이름·표시·잠금을 관리한다',
hint: '레이어를 만들고 이름·표시·잠금을 관리한다',
run: openLayerManager,
},
{
id: 'LAYCURSET',
label: '현재 도면층 설정',
label: '현재 레이어 설정',
glyph: '◉',
hint: '선택한 객체의 도면층을 현재 도면층으로 지정한다',
hint: '선택한 객체의 레이어를 현재 레이어로 지정한다',
tool: Tool.LAYER_CURRENT,
machine: layerCurrentToolStateMachine,
},
{
id: 'LAYOFF',
label: '도면층 끄기',
label: '레이어 끄기',
glyph: '◐',
hint: '선택 객체가 속한 도면층을 숨긴다',
hint: '선택 객체가 속한 레이어를 숨긴다',
tool: Tool.LAYOFF,
machine: layerOffToolStateMachine,
},
@@ -51,14 +51,14 @@ export const LAYER_COMMANDS: CadCommand[] = [
id: 'LAYON',
label: '모두 켜기',
glyph: '◉',
hint: '모든 도면층을 표시한다',
hint: '모든 레이어를 표시한다',
run: turnAllLayersOn,
},
{
id: 'LAYFRZ',
label: '동결',
glyph: '❄',
hint: '도면층을 표시·재생성 대상에서 뺀다',
hint: '레이어를 표시·재생성 대상에서 뺀다',
tool: Tool.LAYFRZ,
machine: layerFreezeToolStateMachine,
},
@@ -66,14 +66,14 @@ export const LAYER_COMMANDS: CadCommand[] = [
id: 'LAYTHW',
label: '동결 해제',
glyph: '☀',
hint: '모든 도면층의 동결을 푼다',
hint: '모든 레이어의 동결을 푼다',
run: thawAllLayers,
},
{
id: 'LAYLCK',
label: '잠금',
glyph: '🔒',
hint: '도면층 객체를 편집할 수 없게 한다',
hint: '레이어 객체를 편집할 수 없게 한다',
tool: Tool.LAYLCK,
machine: layerLockToolStateMachine,
},
@@ -81,7 +81,7 @@ export const LAYER_COMMANDS: CadCommand[] = [
id: 'LAYULK',
label: '잠금 해제',
glyph: '🔓',
hint: '도면층 잠금을 푼다',
hint: '레이어 잠금을 푼다',
tool: Tool.LAYULK,
machine: layerUnlockToolStateMachine,
},
@@ -89,7 +89,7 @@ export const LAYER_COMMANDS: CadCommand[] = [
id: 'LAYISO',
label: '분리',
glyph: '◫',
hint: '선택 객체의 도면층만 남기고 나머지를 숨긴다',
hint: '선택 객체의 레이어만 남기고 나머지를 숨긴다',
tool: Tool.LAYISO,
machine: layerIsolateToolStateMachine,
},
@@ -97,70 +97,70 @@ export const LAYER_COMMANDS: CadCommand[] = [
id: 'LAYUNISO',
label: '분리 해제',
glyph: '◻',
hint: '도면층 분리 이전 상태로 되돌린다',
hint: '레이어 분리 이전 상태로 되돌린다',
run: unisolateLayers,
},
{
id: 'LAYERP',
label: '이전 상태',
glyph: '↺',
hint: '직전 도면층 설정으로 되돌린다',
hint: '직전 레이어 설정으로 되돌린다',
run: restorePreviousLayers,
},
{
id: 'LAYERSTATE',
label: '도면층 상태 저장',
label: '레이어 상태 저장',
aliases: ['LAS'],
glyph: '💾',
hint: '현재 도면층 설정을 이름으로 저장한다',
hint: '현재 레이어 설정을 이름으로 저장한다',
tool: Tool.LAYERSTATE,
machine: layerStateSaveToolStateMachine,
},
{
id: 'LAYERSTATERESTORE',
label: '도면층 상태 복원',
label: '레이어 상태 복원',
glyph: '⭯',
hint: '저장한 도면층 설정을 되돌린다',
hint: '저장한 레이어 설정을 되돌린다',
tool: Tool.LAYERSTATE_RESTORE,
machine: layerStateRestoreToolStateMachine,
},
{
id: 'LAYMCH',
label: '도면층 일치',
label: '레이어 일치',
glyph: '⇄',
hint: '선택 객체를 대상 객체의 도면층으로 옮긴다',
hint: '선택 객체를 대상 객체의 레이어로 옮긴다',
tool: Tool.LAYMCH,
machine: layerMatchToolStateMachine,
},
{
id: 'LAYCUR',
label: '현재 도면층으로',
label: '현재 레이어로',
glyph: '⇥',
hint: '선택 객체를 현재 도면층으로 옮긴다',
hint: '선택 객체를 현재 레이어로 옮긴다',
tool: Tool.LAYCUR,
machine: layerToCurrentToolStateMachine,
},
{
id: 'LAYMRG',
label: '도면층 병합',
label: '레이어 병합',
glyph: '⊎',
hint: '한 도면층의 객체를 다른 도면층으로 옮기고 원래 도면층을 지운다',
hint: '한 레이어의 객체를 다른 레이어로 옮기고 원래 레이어를 지운다',
tool: Tool.LAYMRG,
machine: layerMergeToolStateMachine,
},
{
id: 'LAYDEL',
label: '도면층 삭제',
label: '레이어 삭제',
glyph: '🗑',
hint: '선택한 도면층과 그 객체를 삭제한다',
hint: '선택한 레이어와 그 객체를 삭제한다',
tool: Tool.LAYDEL,
machine: layerDeleteToolStateMachine,
},
{
id: 'LAYWALK',
label: '도면층 탐색',
label: '레이어 탐색',
glyph: '👣',
hint: '부를 때마다 다음 도면층만 표시한다',
hint: '부를 때마다 다음 레이어만 표시한다',
run: walkLayers,
},
];
@@ -287,7 +287,7 @@ export const MODIFY_COMMANDS: CadCommand[] = [
id: 'SETBYLAYER',
label: 'ByLayer로',
glyph: '▤',
hint: '선택 객체의 특성을 도면층 값으로 되돌린다',
hint: '선택 객체의 특성을 레이어 값으로 되돌린다',
tool: Tool.SETBYLAYER,
machine: setByLayerToolStateMachine,
},
@@ -58,7 +58,7 @@ export const PROPERTY_COMMANDS: CadCommand[] = [
},
{
id: 'LWEIGHT',
label: '선가중치',
label: '선 두께',
aliases: ['LW'],
glyph: '▬',
hint: '선 굵기를 지정한다',
@@ -192,7 +192,7 @@ export const INQUIRY_COMMANDS: CadCommand[] = [
id: 'SELECTSIMILAR',
label: '유사 선택',
glyph: '⁝⁝',
hint: '같은 종류·색·도면층 객체를 모두 선택한다',
hint: '같은 종류·색·레이어 객체를 모두 선택한다',
tool: Tool.SELECTSIMILAR,
machine: selectSimilarToolStateMachine,
},
@@ -1,4 +1,4 @@
/** 화면 오른쪽 위 흐린 안내 — 오토캐드에서 저장한 파일을 끌어다 놓는 법 · 누르면 순서 (B07 · M02 양식 둘 다) */
/** 화면 오른쪽 위 흐린 안내 한 줄 — DWG · DXF 끌어다 놓기 · [삽입 → DWG 불러오기] · 누르면 순서 (B07 · M02 양식 둘 다) */
import { type FC, useState } from 'react';
import { isDrawingReadOnly } from '../state';
@@ -12,12 +12,12 @@ export const DropHint: FC = () => {
onClick={() => setOpen((value) => !value)}
title="오토캐드 도형을 이 화면에 붙이는 법"
>
오토캐드에서 WBLOCK(또는 DXFOUT)으로 저장 → 파일을 여기로 끌어다 놓기
DWG · DXF 파일을 끌어다 놓거나 [삽입 → DWG 불러오기]
{open && (
<span className="cad-drop-hint__steps">
<span>① 오토캐드에서 도형을 고르고 WBLOCK(또는 DXFOUT) 입력 → 파일 이름 정해 저장</span>
<span>② 저장한 파일(도면 파일 통째도 됨)을 이 화면으로 끌어다 놓기</span>
<span>③ 원래 좌표 · 도면층 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐</span>
<span>② 저장한 파일(도면 파일 통째도 됨)을 끌어다 놓기 · [삽입 → DWG 불러오기]</span>
<span>③ 원래 좌표 · 레이어 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐</span>
</span>
)}
</button>
@@ -40,7 +40,7 @@ export const InspectorPanel: FC<InspectorPanelProps> = ({ collapsed, onToggleCol
data-active={tab === 'layers'}
onClick={() => openInspector('layers')}
>
도면층
레이어
</button>
</div>
{tab === 'properties' ? (
@@ -161,9 +161,9 @@ export const LayerManager: FC<LayerManagerProps> = ({
))}
</div>
<Button
label="새 도면층"
label="새 레이어"
iconName={IconName.FolderPlus}
title="도면층을 새로 만든다"
title="레이어를 새로 만든다"
onClick={(evt) => handleCreateNewLayer(evt)}
className="w-full mt-2"
/>
@@ -2,25 +2,31 @@
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 { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
import {
commonValue,
LINE_TYPES,
LINE_WIDTHS,
restyle,
selectionLineStyle,
styleTargets,
} from '../helpers/selection-style';
import { getLayers, getSelectedEntities } from '../state';
interface PropertiesEditorProps {
compact?: boolean;
}
function applyToSelection(mutate: (entity: Entity) => void): void {
const selected = getSelectedEntities();
if (!selected.length) return;
for (const entity of selected) mutate(entity);
setEntities([...getEntities()], true);
}
/** 고른 것을 사본으로 바꿈 — 되돌리기 한 칸(488) · 선 특성은 글 뺌 */
const applyToSelection = (mutate: (entity: Entity) => void) => restyle(getSelectedEntities(), mutate);
const applyToLines = (mutate: (entity: Entity) => void) =>
restyle(styleTargets(getSelectedEntities()), mutate);
/** 여러 객체가 값이 다르면 '*가지각색' 대신 첫 객체 값을 보여 준다 (AutoCAD와 같은 관행) */
/** 여러 객체가 값이 다르면 빈 칸(488 · 리본과 같은 규칙 · 고르면 모두에 적용) */
export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false }) => {
const selected = getSelectedEntities();
const layers = getLayers();
const first = selected[0];
const view = selectionLineStyle();
if (!first) {
return <p className="cad-properties__empty">선택된 객체가 없습니다.</p>;
@@ -39,9 +45,10 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
<span>색상</span>
<input
type="color"
value={first.lineColor}
value={view.color || '#000000'}
data-mixed={view.color ? undefined : 'true'}
onChange={(event) =>
applyToSelection((entity) => {
applyToLines((entity) => {
entity.lineColor = event.target.value;
})
}
@@ -49,15 +56,16 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
</label>
<label>
<span>선가중치</span>
<span>선 두께</span>
<select
value={first.lineWidth}
value={view.width}
onChange={(event) =>
applyToSelection((entity) => {
applyToLines((entity) => {
entity.lineWidth = Number(event.target.value);
})
}
>
<option value="" hidden />
{LINE_WIDTHS.map((width) => (
<option key={width} value={width}>
{width}px
@@ -69,14 +77,15 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
<label>
<span>선종류</span>
<select
value={dashToLineType(first.lineDash)}
value={view.lineType}
onChange={(event) => {
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
applyToSelection((entity) => {
applyToLines((entity) => {
entity.lineDash = dash ? [...dash] : undefined;
});
}}
>
<option value="" hidden />
{LINE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
@@ -86,15 +95,16 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
</label>
<label>
<span>도면층</span>
<span>레이어</span>
<select
value={first.layerId}
value={commonValue(selected, (entity) => entity.layerId)}
onChange={(event) =>
applyToSelection((entity) => {
entity.layerId = event.target.value;
})
}
>
<option value="" hidden />
{layers.map((layer) => (
<option key={layer.id} value={layer.id}>
{layer.name}
@@ -1,57 +0,0 @@
import { type FC, useCallback, useEffect, useState } from 'react';
import { HtmlEvent } from '../App.types';
import { requestDrawingNavigation } from '../integration/aislo-drawing-bridge';
import { getDesignMeta } from '../state';
/**
* 도면 내비게이션 바 (CAD 화면 하단 중심).
* 수량 산출표는 HTML 테이블 대신 도면 자체의 CAD 테이블 레이어(b08-cross-table)로
* 렌더되므로, 여기서는 제목·측점정보·확정상태·이전/다음 이동만 담당한다.
*/
export const QuantityPanel: FC = () => {
const [meta, setMeta] = useState(getDesignMeta());
const refresh = useCallback(() => setMeta(getDesignMeta()), []);
useEffect(() => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
if (!meta) return null;
return (
<section className="cad-qtable controls" data-collapsed="true">
<header className="cad-qtable__header">
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasPrev}
title="이전 도면"
onClick={() => requestDrawingNavigation('prev')}
>
‹
</button>
<div className="cad-qtable__title">
<strong>{meta.title}</strong>
{meta.info && <span>{meta.info}</span>}
<em
className="cad-qtable__status"
data-confirmed={meta.confirmed}
title={meta.confirmed ? '설계 확정됨' : '미확정'}
>
{meta.confirmed ? '확정' : '미확정'}
</em>
</div>
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasNext}
title="다음 도면"
onClick={() => requestDrawingNavigation('next')}
>
›
</button>
</header>
</section>
);
};
@@ -1,26 +1,14 @@
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
import { type FC, useEffect, useState } from 'react';
import { HtmlEvent } from '../App.types';
/** 빠른 실행 단추(↶ ↷) — 리본 탭 줄 오른쪽(접기 단추 앞)에 그린다 · 숨김 목록은 DOM 만 둔다 */
import type { FC } from 'react';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
import { getHostTitle, isHostSave } from '../state';
import { QUICK_ACCESS_COMMANDS, QUICK_ACCESS_HIDDEN } from '../ribbon/ribbon.config';
import { isHostSave } from '../state';
export const QuickAccessBar: FC = () => {
const [hostTitle, setTitle] = useState(getHostTitle());
useEffect(() => {
const refresh = () => setTitle(getHostTitle());
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, []);
// 부모 페이지가 저장·이름을 맡는 M02 에선 제목 줄을 숨겨 그림 칸을 넓힌다.
// 부모 [저장]이 맡는 M02 는 우하단 ViewControls 에 ↶ ↷ 가 있어 안 그린다
if (isHostSave()) return null;
return (
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>{hostTitle}</span>
</div>
<div className="cad-quick-access">
{QUICK_ACCESS_COMMANDS.map((id) => {
const command = getCommandById(id);
@@ -29,18 +17,17 @@ export const QuickAccessBar: FC = () => {
<button
type="button"
key={command.id}
hidden={QUICK_ACCESS_HIDDEN.includes(id)}
title={`${command.label} (${command.id})`}
onClick={() => runCommand(command)}
onClick={(event) => {
event.currentTarget.blur();
runCommand(command);
}}
>
<span aria-hidden>{command.glyph}</span>
</button>
);
})}
</div>
<div className="cad-file-state">
<span className="cad-file-state__dot" />
현재 도면
</div>
</header>
);
);
};
@@ -4,11 +4,16 @@ import { runCommandInput } from '../commands/run-command';
import type { Entity } from '../entities/Entity';
import { EntityName } from '../entities/Entity';
import type { TextEntity } from '../entities/TextEntity';
import {
LINE_TYPES,
LINE_WIDTHS,
repaint,
restyle,
selectionLineStyle,
styleTargets,
} from '../helpers/selection-style';
import {
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
getActiveTextStyle,
getEntities,
getLayers,
@@ -21,50 +26,31 @@ import {
setEntities,
} from '../state';
export const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
{ value: 'solid', label: '실선', dash: undefined },
{ value: 'dashed', label: '파선', dash: [10, 5] },
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
{ value: 'dotted', label: '점선', dash: [2, 4] },
];
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
export const dashToLineType = (dash: number[] | undefined): string =>
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
function applyToSelection(mutate: (entity: Entity) => void, trackInUndoStack = true): boolean {
const selected = getSelectedEntities();
if (!selected.length) return false;
for (const entity of selected) {
mutate(entity);
}
setEntities([...getEntities()], trackInUndoStack);
return true;
}
/**
* 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 끄는 동안에는 화면만 칠하고,
* 멎은 뒤 한 번만 되돌리기·통지에 싣는다.
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는
* 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색).
*/
const COLOR_COMMIT_DELAY_MS = 350;
let colorCommitTimer: ReturnType<typeof setTimeout> | undefined;
function paintNowCommitLater(mutate: (entity: Entity) => void): void {
applyToSelection(mutate, false); // 화면만 — 되돌리기·미저장 표시는 아직
let painting: Entity[] | null = null;
function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void {
if (painting) repaint(painting, change);
else painting = restyle(targets, change, false); // 화면만
clearTimeout(colorCommitTimer);
colorCommitTimer = setTimeout(() => {
applyToSelection(() => {}, true); // 멎었다 — 이번 한 번만 실어 보낸다
painting = null;
setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다
}, COLOR_COMMIT_DELAY_MS);
}
/** 고른 선 있으면 그것만 바꿈(488 — 그리기 기본값 그대로) · 없으면 그리기 기본값만 */
const selectedLines = () => styleTargets(getSelectedEntities());
export const PropertiesWidget: FC = () => {
const lineColor = getActiveLineColor();
const lineWidth = getActiveLineWidth();
const lineType = dashToLineType(getActiveLineDash());
const view = selectionLineStyle();
return (
<div className="cad-ribbon-props">
@@ -72,28 +58,32 @@ export const PropertiesWidget: FC = () => {
<span>색상</span>
<input
type="color"
value={lineColor}
value={view.color || '#000000'}
data-mixed={view.color ? undefined : 'true'}
onChange={(event) => {
const color = event.target.value;
setActiveLineColor(color);
paintNowCommitLater((entity) => {
const lines = selectedLines();
if (!lines.length) return setActiveLineColor(color);
paintNowCommitLater(lines, (entity) => {
entity.lineColor = color;
});
}}
/>
</label>
<label className="cad-prop" title="선가중치 (LWEIGHT)">
<span>선가중치</span>
<label className="cad-prop" title="선 두께 (LWEIGHT)">
<span>선 두께</span>
<select
value={lineWidth}
value={view.width}
onChange={(event) => {
const width = Number(event.target.value);
setActiveLineWidth(width);
applyToSelection((entity) => {
const lines = selectedLines();
if (!lines.length) return setActiveLineWidth(width);
restyle(lines, (entity) => {
entity.lineWidth = width;
});
}}
>
<option value="" hidden />
{LINE_WIDTHS.map((width) => (
<option key={width} value={width}>
{width}px
@@ -104,15 +94,17 @@ export const PropertiesWidget: FC = () => {
<label className="cad-prop" title="선종류 (LINETYPE)">
<span>선종류</span>
<select
value={lineType}
value={view.lineType}
onChange={(event) => {
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
setActiveLineDash(dash ? [...dash] : undefined);
applyToSelection((entity) => {
const lines = selectedLines();
if (!lines.length) return setActiveLineDash(dash ? [...dash] : undefined);
restyle(lines, (entity) => {
entity.lineDash = dash ? [...dash] : undefined;
});
}}
>
<option value="" hidden />
{LINE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
@@ -130,10 +122,10 @@ export const LayersWidget: FC = () => {
return (
<div className="cad-ribbon-props">
<label className="cad-prop cad-prop--wide" title="현재 도면층">
<span>현재 도면층</span>
<label className="cad-prop cad-prop--wide" title="현재 레이어">
<span>현재 레이어</span>
<select value={activeLayerId} onChange={(event) => setActiveLayerId(event.target.value)}>
{/* 잠금 도면층(배경)은 고를 수 없으니 보여 주지 않는다. 현재 층이 잠금이면
{/* 잠금 레이어(배경)는 고를 수 없으니 보여 주지 않는다. 현재 층이 잠금이면
(전부 잠금인 도면) 표시가 비지 않게 그 항목만 남긴다. */}
{layers
.filter((layer) => !layer.isLocked || layer.id === activeLayerId)
@@ -148,11 +140,11 @@ export const LayersWidget: FC = () => {
type="button"
className="cad-tool"
data-size="small"
title="도면층 특성 관리자 (LAYER)"
title="레이어 특성 관리자 (LAYER)"
onClick={() => runCommandInput('LAYER')}
>
<span className="cad-tool__glyph">▤</span>
<span className="cad-tool__label">도면층</span>
<span className="cad-tool__label">레이어</span>
</button>
</div>
);
@@ -163,14 +155,11 @@ export const TextStyleWidget: FC = () => {
const handle = (patch: Partial<typeof textStyle>, live = false) => {
setActiveTextStyle(patch);
const mutate = (entity: Entity) => {
if (entity.getType() === EntityName.Text) {
(entity as TextEntity).setTextOptions(patch);
}
};
const texts = getSelectedEntities().filter((entity) => entity.getType() === EntityName.Text);
const mutate = (entity: Entity) => (entity as TextEntity).setTextOptions(patch);
// 색만 드래그 중 연발한다 — 나머지 항목은 한 번 고르면 끝이라 바로 싣는다.
if (live) paintNowCommitLater(mutate);
else applyToSelection(mutate);
if (live) paintNowCommitLater(texts, mutate);
else restyle(texts, mutate);
};
return (
@@ -1,5 +1,5 @@
/**
* CAD 화면 골격 조립 — 제목표시줄 · 리본 · 좌측 팔레트 · 탐색막대 · 명령행 · 상태막대.
* CAD 화면 골격 조립 — 리본(빠른 실행 포함) · 좌측 팔레트 · 탐색막대 · 명령행 · 상태막대.
* 각 조각은 자기 파일에 있고, 여기서는 배치와 표시 상태만 다룬다.
*/
import { type FC, useEffect, useState } from 'react';
@@ -8,7 +8,6 @@ import { InspectorPanel } from './InspectorPanel';
import { QuickProperties } from './QuickProperties';
import { BlockLibraryPanel } from './BlockLibraryPanel';
import { LayersWidget, PropertiesWidget, TextStyleWidget } from './RibbonWidgets';
import { QuickAccessBar } from './QuickAccessBar';
import { Ribbon } from '../ribbon/Ribbon';
import { StatusBar } from './StatusBar';
import { useCadRefresh } from './use-cad-refresh';
@@ -50,7 +49,6 @@ export const Toolbar: FC = () => {
return (
<>
<QuickAccessBar />
<Ribbon
activeTool={activeTool}
widgets={{
@@ -526,9 +526,13 @@ export class ScreenCanvasDrawController implements DrawController {
}
/** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */
public measureTextScreen(label: string, fontSize: number): number {
public measureTextScreen(
label: string,
fontSize: number,
font: Partial<{ fontFamily: string; bold: boolean; italic: boolean }> = {}
): number {
this.context.save();
this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`;
this.context.font = `${font.italic ? 'italic ' : ''}${font.bold ? 'bold ' : ''}${fontSize}px ${font.fontFamily ?? DEFAULT_TEXT_OPTIONS.fontFamily}`;
const width = this.context.measureText(label).width;
this.context.restore();
return width;
@@ -4,7 +4,19 @@ import type { Shape, SnapPoint } from '../App.types';
import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
import { scalePoint } from '../helpers/scale-point.ts';
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
import {
textFrame,
textFrameCorners,
textFrameDistance,
textLinesOf,
} from '../helpers/text-frame.ts';
import {
getActiveLayerId,
getEntities,
getScreenCanvasDrawController,
isEntityHighlighted,
isEntitySelected,
} from '../state.ts';
import { type Entity, EntityName, type JsonEntity } from './Entity';
import type { LineEntity } from './LineEntity.ts';
@@ -71,15 +83,14 @@ export class TextEntity implements Entity {
drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options);
// 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이
// 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가
// 없어 이 선이 실리지 않는다.
// 없어 이 선이 실리지 않는다. 테두리 = 더블클릭 입력 칸과 같은 셈(`text-frame.ts` · 487) ·
// 여러 줄은 묶음 하나(표시된 첫 줄이 그림).
if (highlighted || selected) {
const box = this.getBoundingBox();
const corners = [
new Point(box.xmin, box.ymin),
new Point(box.xmax, box.ymin),
new Point(box.xmax, box.ymax),
new Point(box.xmin, box.ymax),
];
const lines = textLinesOf(this, getEntities());
const marked = (line: TextEntity) => isEntityHighlighted(line) || isEntitySelected(line);
if (lines.length > 1 && lines.find(marked) !== this) return;
const corners = this.frameOf(lines)?.corners;
if (!corners) return;
drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]);
for (let index = 0; index < corners.length; index++) {
drawController.drawLine(corners[index], corners[(index + 1) % corners.length]);
@@ -201,8 +212,28 @@ export class TextEntity implements Entity {
return this.basePoint;
}
/** 잡는 범위 = 글 테두리(안 = 0 · 밖 = 테두리까지) · 화면이 없으면(시험 · 내보내기) 기준점 */
public distanceTo(shape: Shape): [number, Segment] | null {
return this.basePoint.distanceTo(shape);
// 점만(x · y) — instanceof 는 flatten 을 따로 실은 시험에서 갈림
const frame = typeof (shape as Point).x === 'number' ? this.frameOf([this]) : null;
if (!frame) return this.basePoint.distanceTo(shape);
return textFrameDistance([this], frame.frame, frame.scale, shape as Point);
}
/** 글 테두리(화면 기준 셈) — 화면 그리기 제어기가 없으면 null */
private frameOf(lines: TextEntity[]) {
let controller: ReturnType<typeof getScreenCanvasDrawController>;
try {
controller = getScreenCanvasDrawController();
} catch {
return null;
}
const scale = controller.getScreenScale();
const rows = lines.map((line) => line.previewLabel ?? line.label);
const frame = textFrame(lines, rows, scale, (row, px, font) =>
controller.measureTextScreen(row, px, font)
);
return { frame, scale, corners: textFrameCorners(lines, frame, scale) };
}
public getSvgString(): string | null {
@@ -17,6 +17,8 @@ import {
} from '../state';
import { Point } from '@flatten-js/core';
import { isEntityHidden } from './visibility';
import type { TextEntity } from '../entities/TextEntity';
import { textFrame, textFrameCorners, textLinesOf } from './text-frame';
export function registerCadDebugHook(): void {
(window as unknown as Record<string, unknown>).__aisloCad = {
@@ -59,5 +61,21 @@ export function registerCadDebugHook(): void {
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
return { x: point.x, y: point.y };
},
// 글 스침 테두리 네 귀(화면 좌표 · 왼위부터) — 더블클릭 입력 칸과 픽셀로 견줌(487).
textFrame: (id: string) => {
const entity = getEntities().find((item) => item.id === id) as TextEntity | undefined;
if (!entity?.getTextOptions) return null;
const controller = getScreenCanvasDrawController();
const lines = textLinesOf(entity, getEntities());
const scale = controller.getScreenScale();
const rows = lines.map((line) => line.previewLabel ?? line.getLabel());
const frame = textFrame(lines, rows, scale, (row, px, font) =>
controller.measureTextScreen(row, px, font)
);
return textFrameCorners(lines, frame, scale).map((corner) => {
const point = controller.worldToTarget(corner);
return { x: point.x, y: point.y };
});
},
};
}
@@ -30,7 +30,7 @@ export function drawEntities(drawController: DrawController, entities: Entity[])
continue;
}
if (!layer?.isVisible || layer.isFrozen) {
continue; // 꺼졌거나 동결된 도면층은 그리지 않는다
continue; // 꺼졌거나 동결된 레이어는 그리지 않는다
}
if (isEntityHidden(entity)) {
continue; // ISOLATEOBJECTS·HIDEOBJECTS로 숨긴 객체
@@ -6,6 +6,6 @@ export function getNewLayer(): Layer {
id: crypto.randomUUID(),
isLocked: false,
isVisible: true,
name: `새 도면층 ${getLayers().length}${1}`,
name: `새 레이어 ${getLayers().length}${1}`,
};
}
@@ -1,11 +1,20 @@
/** 파일 선택창을 띄우고 고른 파일 하나를 돌려준다. 취소하면 아무 일도 없다. */
/**
* 파일 선택창을 띄우고 고른 파일 하나를 돌려준다. 취소하면 아무 일도 없다.
* 입력은 고르는 동안 문서에 숨겨 붙임(`.cad-pick-file` · 자동 시험 업로드가 닿게) · 고르거나 취소하면 뗌.
*/
export function pickFile(accept: string, onPicked: (file: File) => void): void {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.hidden = true;
input.className = 'cad-pick-file';
document.querySelectorAll('.cad-pick-file').forEach((old) => old.remove());
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (file) onPicked(file);
});
input.addEventListener('cancel', () => input.remove());
document.body.append(input);
input.click();
}
@@ -0,0 +1,108 @@
/**
* 고른 것의 선 특성(색 · 선 두께 · 선종류) 읽기 · 바꾸기 한 벌 (488 · 리본 특성 · 특성 판 · 특성 명령).
*
* 읽기 = 고른 것의 선 잎(폴리선 → 자식 선 · 글 뺌)이 모두 같으면 그 값 · 다르거나 목록 밖이면 빈 값 ·
* 고른 선이 없으면 그리기 기본값.
* 바꾸기 = id 그대로 사본으로 갈아 끼움(폴리선 자식까지) — 되돌리기 한 칸 · Ctrl+Z 한 번에 옛 값.
*/
import { type Entity, EntityName } from '../entities/Entity';
import type { PolyLineEntity } from '../entities/PolyLineEntity';
import {
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
getEntities,
getSelectedEntities,
notifyWindow,
setEntities,
} from '../state';
import { HtmlEvent } from '../App.types';
import { cloneKeepingLook } from '../tools/move-tool.helpers';
export const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
{ value: 'solid', label: '실선', dash: undefined },
{ value: 'dashed', label: '파선', dash: [10, 5] },
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
{ value: 'dotted', label: '점선', dash: [2, 4] },
];
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
/** 점선 → 목록 값 · `[]` · 없음 = 실선 · 목록 밖 = 빈 값 */
export const dashToLineType = (dash: number[] | undefined): string =>
dash?.length
? (LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? '')
: 'solid';
/** 선 메뉴가 보는 것 — 글 뺌(글 색은 문자 스타일 칸) */
export const styleTargets = (entities: Entity[]): Entity[] =>
entities.filter((entity) => entity.getType() !== EntityName.Text);
/** 그림에 쓰는 잎 — 폴리선은 자식 선이 제 색 · 두께로 그림 */
const leaves = (entity: Entity): Entity[] =>
entity.getType() === EntityName.PolyLine ? (entity as PolyLineEntity).getEntities() : [entity];
/** 모두 같으면 그 값 · 다르거나 없으면 `''`(빈 칸) */
export function commonValue(entities: Entity[], read: (entity: Entity) => string): string {
const values = new Set(entities.flatMap(leaves).map(read));
return values.size === 1 ? [...values][0] : '';
}
export interface LineStyleView {
color: string;
width: string;
lineType: string;
}
/** 리본 · 특성 판이 보일 값 — 고른 선 있으면 그 값(다르면 `''`) · 없으면 그리기 기본값 */
export function selectionLineStyle(): LineStyleView {
const targets = styleTargets(getSelectedEntities());
if (!targets.length) {
return {
color: getActiveLineColor(),
width: String(getActiveLineWidth()),
lineType: dashToLineType(getActiveLineDash()),
};
}
const width = commonValue(targets, (entity) => String(entity.lineWidth));
return {
color: commonValue(targets, (entity) => entity.lineColor.toLowerCase()),
width: LINE_WIDTHS.map(String).includes(width) ? width : '',
lineType: commonValue(targets, (entity) => dashToLineType(entity.lineDash)),
};
}
/** 사본에 바꿈 — 폴리선은 자식까지 */
function applyDeep(entity: Entity, change: (entity: Entity) => void): void {
change(entity);
if (entity.getType() === EntityName.PolyLine) {
for (const child of (entity as PolyLineEntity).getEntities()) change(child);
}
}
/**
* 바꾼 사본(id 그대로)을 제자리에 끼움 · `track` = 되돌리기 한 칸.
* 돌려준 사본은 이어서 칠할 수 있음(`repaint` · 색 끌기).
*/
export function restyle(targets: Entity[], change: (entity: Entity) => void, track = true): Entity[] {
if (!targets.length) return [];
const copies = targets.map((entity) => {
const copy = cloneKeepingLook(entity, true);
applyDeep(copy, change);
return copy;
});
const byId = new Map(copies.map((copy) => [copy.id, copy]));
setEntities(
getEntities().map((entity) => byId.get(entity.id) ?? entity),
track
);
notifyWindow(HtmlEvent.UPDATE_STATE);
return copies;
}
/** 이미 끼운 사본을 다시 칠함(화면만 · 되돌리기 없음) */
export function repaint(copies: Entity[], change: (entity: Entity) => void): void {
for (const copy of copies) applyDeep(copy, change);
setEntities([...getEntities()], false);
notifyWindow(HtmlEvent.UPDATE_STATE); // 견본 색도 따라감
}
@@ -0,0 +1,150 @@
/**
* 글 테두리 한 벌(487 · 요구 280-6) — 스침 · 고름 테두리(`TextEntity.draw`) · 더블클릭 입력 칸
* (`integration/text-inplace-edit.ts`) · 잡는 범위(`TextEntity.distanceTo`)가 같은 셈을 씀.
* 테두리 = 글 방향 축(u 오른쪽 · v 위) · 첫 줄 기준점 = 0 · 단위 = 화면 px.
* 정렬(왼 · 가운데 · 오른) · 가운데 맞춤(textBaseline middle) · 실제 글꼴 폭 · 회전 · 여러 줄 묶음 · 도각 칸.
* `TextEntity.getBoundingBox` 는 안 씀(줄맞춤 · 그립 · 공간 색인 · 창 고르기 몫).
*/
import { Point, Segment } from '@flatten-js/core';
import { type Entity, EntityName } from '../entities/Entity';
import type { TextEntity, TextOptions } from '../entities/TextEntity';
/** 양옆 여유(× 글 크기) — 옛 입력 칸 폭 여유 1.5 를 반씩 */
export const TEXT_FRAME_PAD = 0.75;
/** 한 줄 높이(× 글 크기) */
export const TEXT_LINE_RATIO = 1.2;
/** 화면 글 폭(px) — 그리기와 같은 글꼴(굵게 · 기울임 포함)로 잼 */
export type MeasureText = (
label: string,
fontPx: number,
font: Pick<TextOptions, 'fontFamily' | 'bold' | 'italic'>
) => number;
export interface TextFrame {
left: number;
right: number;
top: number;
bottom: number;
/** 양옆 여유(px) — 입력 칸 안쪽 여백 */
pad: number;
fontPx: number;
linePx: number;
}
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
let groupMemo: { entities: Entity[]; groups: Map<string, Entity[]> } | null = null;
const groupOf = (entities: Entity[], groupId: string): Entity[] => {
if (groupMemo?.entities !== entities) {
const groups = new Map<string, Entity[]>();
for (const entity of entities) {
if (!entity.groupId) continue;
const members = groups.get(entity.groupId);
if (members) members.push(entity);
else groups.set(entity.groupId, [entity]);
}
groupMemo = { entities, groups };
}
return groupMemo.groups.get(groupId) ?? [];
};
/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */
export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] {
if (!target.groupId) return [target];
const group = groupOf(entities, target.groupId);
const x = basePointOf(target).x;
const isMultiline =
group.length > 1 &&
group.every(
(entity) =>
entity.getType() === EntityName.Text &&
Math.abs(basePointOf(entity as TextEntity).x - x) < 1e-6
);
if (!isMultiline) return [target];
return ([...group] as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y);
}
/** 글 묶음 테두리(px) — rows = 보일 글(입력 칸은 친 글) · scale = 화면 px / 월드 · minFontPx = 입력 칸 최소 글 크기 */
export function textFrame(
lines: TextEntity[],
rows: string[],
scale: number,
measure: MeasureText,
minFontPx = 0
): TextFrame {
const options = lines[0].getTextOptions();
const fontPx = Math.max(options.fontSize * scale, minFontPx);
if (options.boxWidth && options.boxHeight) {
// 도각 자리표 = 칸이 곧 테두리(기준점 = 칸 한가운데)
const width = options.boxWidth * scale;
const height = options.boxHeight * scale;
return {
left: -width / 2,
right: width / 2,
top: height / 2,
bottom: -height / 2,
pad: 0,
fontPx,
linePx: height,
};
}
const linePx =
lines.length > 1
? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx)
: fontPx * TEXT_LINE_RATIO;
const widest = Math.max(0, ...rows.map((row) => measure(row, fontPx, options)));
const pad = fontPx * TEXT_FRAME_PAD;
const start =
options.textAlign === 'left' ? 0 : options.textAlign === 'right' ? -widest : -widest / 2;
return {
left: start - pad,
right: start + widest + pad,
top: linePx / 2,
bottom: linePx / 2 - linePx * Math.max(rows.length, 1),
pad,
fontPx,
linePx,
};
}
/** 글 방향 단위 벡터(월드 · 위 +y) */
const axesOf = (entity: TextEntity): { ux: number; uy: number } => {
const { x, y } = entity.getTextOptions().textDirection;
const length = Math.hypot(x, y) || 1;
return { ux: x / length, uy: y / length };
};
/** 테두리 네 귀(월드) — 왼위 · 오른위 · 오른아래 · 왼아래 */
export function textFrameCorners(lines: TextEntity[], frame: TextFrame, scale: number): Point[] {
const base = basePointOf(lines[0]);
const { ux, uy } = axesOf(lines[0]);
const at = (u: number, v: number) =>
new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
return [
at(frame.left, frame.top),
at(frame.right, frame.top),
at(frame.right, frame.bottom),
at(frame.left, frame.bottom),
];
}
/** 점(월드)과 테두리 거리(월드 · 안 = 0) · 테두리 위 가장 가까운 점까지 선분 */
export function textFrameDistance(
lines: TextEntity[],
frame: TextFrame,
scale: number,
point: Point
): [number, Segment] {
const base = basePointOf(lines[0]);
const { ux, uy } = axesOf(lines[0]);
const dx = (point.x - base.x) * scale;
const dy = (point.y - base.y) * scale;
const u = Math.min(Math.max(dx * ux + dy * uy, frame.left), frame.right);
const v = Math.min(Math.max(-dx * uy + dy * ux, frame.bottom), frame.top);
const nearest = new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
return [
Math.hypot(point.x - nearest.x, point.y - nearest.y),
new Segment(new Point(point.x, point.y), nearest),
];
}
@@ -1,10 +1,10 @@
/* 손가락 기기 명령 단추(기기대응 4-1 · `input-touch.ts`) — 자판 없이 Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅.
마우스만 있는 화면엔 안 보임. 그림 면 오른쪽 가운데에 세로로. */
자판 없는 기기(손가락이 주 입력 · 마우스 스침 없음)만 보임 — 터치 노트북엔 안 보임(요구 280-6). 그림 면 오른쪽 가운데에 세로로. */
.cad-touch-bar {
display: none;
}
@media (any-pointer: coarse) {
@media (hover: none) and (pointer: coarse) {
.cad-touch-bar {
position: fixed;
top: 50%;
@@ -41,7 +41,6 @@ export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b08:drawing-error';
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed';
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
/** 작도 영역 가이드(169-1 · 169-3) — 부모 숫자 칸 → CAD 갈기 · CAD 끌기 → 부모 숫자 칸 */
@@ -95,11 +94,6 @@ function notifyParent(type: string, payload: Record<string, unknown> = {}) {
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
export function requestHostSave() {
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
@@ -1,8 +1,8 @@
/**
* 오토캐드 도형 붙이기 — 두 길 모두 서버가 DXF 를 도형으로 바꿔 현재 도면에 원좌표 그대로 붙이고
* 고른 상태로 둔다(되돌리기 한 번에 빠짐).
* - 끌어다 놓기: 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일, 또는 도면 파일 통째.
* 끄는 동안 `body.cad-file-dragging` 으로 흐린 테두리.
* - 끌어다 놓기 · [삽입 → DWG 불러오기](IMPORT · 488): 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일,
* 또는 도면 파일 통째 · 붙인 범위로 화면 맞춤. 끄는 동안 `body.cad-file-dragging` 으로 흐린 테두리.
* - Ctrl+V: 시스템 클립보드 글이 DXF 면 같은 길 · 아니면 앱 안 붙여넣기(PASTECLIP).
* 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 —
* `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도).
@@ -10,10 +10,14 @@
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command';
import { hasClipboardContent } from '../helpers/cad-clipboard';
import { fitView } from '../drawControllers/fit-view';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
import { changeView } from '../inputController/zoom-prompt';
import {
addEntities,
getLayers,
getScreenCanvasDrawController,
getSelectedEntities,
isDrawingReadOnly,
setLayers,
setSelectedEntityIds,
@@ -73,6 +77,24 @@ function refuseReadOnly(): boolean {
return true;
}
/** 붙인 것(골라진 채)이 화면에 다 보이게 — ZOOM 이전(P)으로 돌아감 */
function fitToSelection(count: number): number {
const picked = getSelectedEntities();
const controller = getScreenCanvasDrawController();
if (picked.length)
changeView(() => {
const { zoom, offset } = fitView(picked, controller.getCanvasSize());
controller.setScreenScale(zoom);
controller.setScreenOffset(offset);
});
return count;
}
/** DWG · DXF 파일 → 지금 도면에 더함(원좌표 · 골라진 채 · 화면 맞춤 · 되돌리기 한 번) */
export function importDrawingFile(file: File): void {
if (!refuseReadOnly()) reportPaste(pasteDrawingFile(file).then(fitToSelection));
}
export function registerDxfClipPaste(): void {
document.addEventListener('paste', (event: ClipboardEvent) => {
if ((event.target as HTMLElement | null)?.closest?.('input, textarea, select')) return;
@@ -101,6 +123,6 @@ export function registerDxfClipPaste(): void {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer?.files[0];
if (file && !refuseReadOnly()) reportPaste(pasteDrawingFile(file));
if (file) importDrawingFile(file);
});
}
@@ -1,4 +1,4 @@
/* 앱 ☰ 자리 비키기(기기대응 7-2) — 값은 host-burger.ts 가 적음 · 틀 없이 띄우면 0 */
html .cad-titlebar {
padding-left: max(12px, var(--host-burger-pad, 0px));
html .cad-ribbon-tabs {
padding-left: max(8px, var(--host-burger-pad, 0px));
}

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