diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 304bdb07f..ccd46475f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -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"))); // 새 창 크기 기준으로 다시 맞춘다. diff --git a/A00_Common/main.ts b/A00_Common/main.ts index e7ea76d3e..647de8f24 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -13,7 +13,11 @@ import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시( 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())); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a10dba7c8..520474528 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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; }, diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 5351b86cd..038d95855 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -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; /** 편집 창 `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, curves: ReadonlyArray, intervalM: number, ): number[] { if (!(intervalM > 0)) return []; + const tol = mcTolM(intervalM); const hits = new Set(); 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, 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, + limits: FitLimits, + stationM: number, +): Map { + const built = build(before, before.planned, limits); + const out = new Map(); + 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) | 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, limits: FitLimits, from: number, + before?: (Columns & Pick) | 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(); + 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) | null | undefined, + now: Columns & Pick, + 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, base: ReadonlyArray, k: number, limits: FitLimits, + before?: (Columns & Pick) | 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); } diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 92285c869..b034c665a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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 { + 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; + defaults: Record; + ranges: Record; + /** 종류마다 저장본(확정) 장 수 — 바꾸면 풀리므로 화면이 먼저 물음. */ + saved: Record; + /** PUT 응답만 — 이번에 풀린 저장본 장 수. */ + invalidated?: Partial>; +} + +export function fetchDrawingScales(projectId: string): Promise { + return requestJson(`/projects/${projectId}/drawing-scales`); +} + +/** 축척 저장 — 보낸 종류만 바꿈 · 값이 바뀐 종류의 저장본은 서버가 풂. */ +export function saveDrawingScales( + projectId: string, + scales: Partial>, +): Promise { + return requestJson(`/projects/${projectId}/drawing-scales`, { + method: "PUT", + body: JSON.stringify({ scales }), + }); +} + /** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */ export interface PlanKind { id: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts index 9a4917a1c..2df3e3d50 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts @@ -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[] = []; 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, diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 8fd7616f8..0af68f0fe 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -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); diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index ab66e7bfc..096427eb8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -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). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py index e1000df42..9d8741b41 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Development_Draw.py @@ -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, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py index 4557d4b7f..e52e08bc8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py @@ -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"])), }, ) ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index c749dac21..32be0342c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -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", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 1fa353f86..d1d5e4b0c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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)}, ) ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py index ae6ce9982..9df19f705 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -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}" diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 3b290e37b..0c2aea39b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -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: diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 291e865da..2f5060c34 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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).""" diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py index 6bfce8124..88fa28340 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py @@ -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 {} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py new file mode 100644 index 000000000..f7e1ee090 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py @@ -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)}) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index 20180f015..58a252743 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -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가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다. * ========================================================================== */ diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Development.ts b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts index 5203ea0b2..002d8e32d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Development.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts @@ -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) { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 368f5c4a3..e990d0d76 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 { const cad = createCadHost({ 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 { 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 { ); }; - /** 장 배치 칸 자리(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 | undefined; + const crossLayout = (): Promise => + (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 { */ 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( `.b07-drawing-button[data-drawing-id="${drawingId}"]`, @@ -265,7 +198,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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 { return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + - "남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.", + "남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.", ); }; @@ -347,9 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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 { } 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 { const requestCadDrawing = (): Promise => cad.requestSave(); - async function confirmCurrentDrawing(): Promise { - 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 { - 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 { 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 => { + 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 { 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); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 55af92418..7f805e943 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -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(), ): 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 = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 684029b0a..8b8971d54 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index af52ce824..b4dbe70bd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -34,6 +34,8 @@ export interface PlanManageControls { projectId: string; /** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */ onClosed: (changed: boolean, openId?: string) => void; + /** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */ + actions?: HTMLElement[]; } function el( @@ -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() }; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts new file mode 100644 index 000000000..94d40d7f5 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts @@ -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; + current: () => DesignDrawingItem | undefined; + /** CAD 에 저장 안 한 편집이 있나 · 지움 */ + cadDirty: () => boolean; + setCadClean: () => void; + requestCad: () => Promise>; + isFrameEditing: () => boolean; + /** 도면 캐시에서 뺌 — 다음 열기에 서버에서 다시 받음 */ + forget: (id: string) => void; + /** 그 도면을 서버에서 다시 받아 실음 */ + reload: (drawing: DesignDrawingItem) => Promise; +} + +/** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */ +function markButton(id: string, saved: boolean): void { + document + .querySelectorAll(`.b07-drawing-button[data-drawing-id="${id}"]`) + .forEach((button) => (button.dataset.confirmed = String(saved))); +} + +/** + * 고친 도면을 저장 — 지금 탭(CAD 에서 받음 · 횡단은 수량표 같이) + 다른 고친 탭(받아 둔 편집본). + * 고친 것 없으면 요청 없이 참 · 하나라도 실패하면 알림 뒤 거짓(부르는 쪽은 머묾). + */ +export async function saveEditedDrawings(store: DrawingStore): Promise { + 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 { + 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(); + } +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts new file mode 100644 index 000000000..e4012364c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Settings.ts + * 좌측 도면 묶음 ⚙ 설정 모달(요구 280-5) — 공용 `openModal` · 머리 [원본에서 다시 그리기](281). + * + * 평면도 = 평면도 관리 모달 그대로(`openPlanManager`) + * 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김 + * 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 토적 · 전개도 = 몸 비움(축척 칸은 485-2) + * [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬 + * ========================================================================== */ + +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"; + +type Kind = DesignDrawingItem["kind"]; + +export interface DrawingSettingsDeps { + drawings: DesignDrawingItem[]; + current: () => DesignDrawingItem | undefined; + /** 지금 도면에 버릴 것(저장본 · 고친 것)이 있나 */ + canRedraw: () => boolean; + /** 원본에서 다시 그리기 — 해냈으면 참(모달 닫음) */ + redraw: () => Promise; + plan?: PlanManageControls; + cross: { + layout: () => Promise; + /** 배치 저장 — 모달은 닫힌 뒤 · `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 그림 — 축척 없음")); + 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; + 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("배치를 받지 못했습니다."))); +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 0ee251ae8..bd92bbb2c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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,34 @@ 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; +} diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index ba1b7de29..ce7c76163 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -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; @@ -540,7 +485,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 +663,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; - } } /* 특성 팔레트 — 값을 바로 고치는 입력들 */ diff --git a/B07_DesignDetail/openwebcad/src/App.tsx b/B07_DesignDetail/openwebcad/src/App.tsx index 93ce68b1d..370698bba 100644 --- a/B07_DesignDetail/openwebcad/src/App.tsx +++ b/B07_DesignDetail/openwebcad/src/App.tsx @@ -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 (
-
diff --git a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json index 634771019..fc10f1907 100644 --- a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json +++ b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json @@ -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": []}, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts index 6bdeb8ebc..6cb2b8d73 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts @@ -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 파일 선택'; + }, + }, ]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts b/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts index d5ea2b0fb..c5437d0e2 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts @@ -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, }, ]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts index a4bae8470..d61f0a42d 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -287,7 +287,7 @@ export const MODIFY_COMMANDS: CadCommand[] = [ id: 'SETBYLAYER', label: 'ByLayer로', glyph: '▤', - hint: '선택 객체의 특성을 도면층 값으로 되돌린다', + hint: '선택 객체의 특성을 레이어 값으로 되돌린다', tool: Tool.SETBYLAYER, machine: setByLayerToolStateMachine, }, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts b/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts index 038e923e0..5ef6af6c0 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts @@ -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, }, diff --git a/B07_DesignDetail/openwebcad/src/components/DropHint.tsx b/B07_DesignDetail/openwebcad/src/components/DropHint.tsx index 8f489acc7..8d45496c4 100644 --- a/B07_DesignDetail/openwebcad/src/components/DropHint.tsx +++ b/B07_DesignDetail/openwebcad/src/components/DropHint.tsx @@ -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 && ( ① 오토캐드에서 도형을 고르고 WBLOCK(또는 DXFOUT) 입력 → 파일 이름 정해 저장 - ② 저장한 파일(도면 파일 통째도 됨)을 이 화면으로 끌어다 놓기 - ③ 원래 좌표 · 도면층 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐 + ② 저장한 파일(도면 파일 통째도 됨)을 끌어다 놓기 · [삽입 → DWG 불러오기] + ③ 원래 좌표 · 레이어 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐 )} diff --git a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx index b640e300d..3807a356a 100644 --- a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx @@ -40,7 +40,7 @@ export const InspectorPanel: FC = ({ collapsed, onToggleCol data-active={tab === 'layers'} onClick={() => openInspector('layers')} > - 도면층 + 레이어 {tab === 'properties' ? ( diff --git a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx index 0191975f0..6bd315d6d 100644 --- a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx +++ b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx @@ -161,9 +161,9 @@ export const LayerManager: FC = ({ ))}