diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 92285c869..084ba4627 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; }[]; @@ -205,6 +208,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_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..17f4f06ed 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 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/main.py b/main.py index 8ffc770ba..f7448a5bb 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cros from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router +from B07_DesignDetail.B07_DesignDetail_Router_Support_Scale import router as b07_scale_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router @@ -654,6 +655,7 @@ app.include_router(b06_section_cross_table_router, dependencies=protected_with_c app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) +app.include_router(b07_scale_router, dependencies=protected_with_company) app.include_router(b08_hydro_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) diff --git a/resources/tester/test_280_5_b07_drawing_scales.py b/resources/tester/test_280_5_b07_drawing_scales.py new file mode 100644 index 000000000..98c74390b --- /dev/null +++ b/resources/tester/test_280_5_b07_drawing_scales.py @@ -0,0 +1,318 @@ +"""485-1 B07 도면 X · Y 축척(요구 280-5) — 프로젝트마다 `drawing_scales.json`. + +값 없음 = 지금 결과 그대로 · 종단 X 바꿈 = 장 수 · 도각 축척 글 · 횡단 X ≠ Y 덧그림 자리 · +토적 · 전개도 · 범위 밖 거절 · 바꾼 종류만 저장본 풂. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B07_DesignDetail import B07_DesignDetail_Engine_Cad_Development as dev +from B07_DesignDetail import B07_DesignDetail_Router_Support_Scale as scale_router +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import build_cross_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import ( + build_development_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( + build_longitudinal_drawing, + longitudinal_chunks, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + DRAWING_SCALE_DEFAULTS, + drawing_scales_path, + entities_bbox, + read_drawing_scales, + use_project_templates, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest +from B07_DesignDetail.B07_DesignDetail_Router_Support_Scale import ( + checked_scales, + saved_counts, + write_drawing_scales, +) + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = Path(__file__).parent / "fixtures" / "b07_cross_overlay_sections.json" +NODE_MODULES = ROOT / "config" / "node_modules" +EXTENT_BUNDLE = ROOT / "config" / "b07_extent_node" / "B07_DesignDetail_Cross_Extent_Node.js" + +SOURCE = { + "chainage_m": 0.0, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.2 * x} + for x in range(-10, 11) + ], +} +DESIGN_LINE = [ + {"offset_m": -4.0, "elevation_m": 99.0}, + {"offset_m": 0.0, "elevation_m": 98.5}, + {"offset_m": 4.0, "elevation_m": 99.5}, +] + + +@pytest.fixture +def project(tmp_path: Path): + """빈 프로젝트 폴더를 이 요청 프로젝트로 — 끝나면 시스템 양식으로(문맥 변수 새지 않게).""" + use_project_templates(tmp_path) + yield tmp_path + use_project_templates(None) + + +def _set(project: Path, scales: dict) -> None: + path = drawing_scales_path(project) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(scales), encoding="utf-8") + + +def _longitudinal(count: int = 66, interval: float = 20.0) -> dict: + xs = [index * interval for index in range(count)] + return { + "samples": [{"valid": True, "chainage_m": x, "elevation_m": 100.0 + 0.05 * x} for x in xs], + "stations": [{"chainage_m": x} for x in xs], + "design_profiles": [ + {"samples": [{"chainage_m": x, "elevation_m": 100.5 + 0.05 * x} for x in xs]} + ], + } + + +def _texts(drawing: dict) -> list[str]: + out: list[str] = [] + + def walk(entity: dict) -> None: + label = (entity.get("shapeData") or {}).get("label") + if isinstance(label, str): + out.append(label) + for child in entity.get("children") or []: + walk(child) + + for entity in drawing["entities"]: + walk(entity) + return out + + +def _ground_xy(drawing: dict) -> list[tuple[float, float]]: + ground = next(e for e in drawing["entities"] if e["layerId"] == "b08-ground") + return [ + (child["shapeData"]["startPoint"]["x"], child["shapeData"]["startPoint"]["y"]) + for child in ground["children"] + ] + + +def test_값_없음_기본_그대로(project: Path) -> None: + assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS + assert read_drawing_scales(None) == DRAWING_SCALE_DEFAULTS + with_root = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") + longitudinal = _longitudinal() + chunks = longitudinal_chunks(longitudinal) + use_project_templates(None) + without = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") + assert with_root == without + assert longitudinal_chunks(longitudinal) == chunks + # 깨진 파일 · 어긋난 값 = 기본 + use_project_templates(project) + drawing_scales_path(project).parent.mkdir(parents=True, exist_ok=True) + drawing_scales_path(project).write_text("{깨짐", encoding="utf-8") + assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS + _set(project, {"cross": {"x": -5, "y": True}, "longitudinal": "x"}) + assert read_drawing_scales(project) == DRAWING_SCALE_DEFAULTS + + +def test_종단_X_바꿈_장_수_그림_폭_도각_글(project: Path) -> None: + longitudinal = _longitudinal() + before = longitudinal_chunks(longitudinal) + first = build_longitudinal_drawing(longitudinal, before[0]["id"], before[0]) + _set(project, {"longitudinal": {"x": 500, "y": 200}}) + after = longitudinal_chunks(longitudinal) + # 1/500 = 1 m 당 2 mm — 한 장에 담는 측점이 줄어 장이 늘어남 · id 도 다시 매김 + assert len(after) > len(before) + assert [c["id"] for c in after] == [f"longitudinal_{n}" for n in range(1, len(after) + 1)] + chunk = {**before[0], "id": "probe"} + wide = build_longitudinal_drawing(longitudinal, "probe", chunk) + + def width(drawing: dict) -> float: + ground = [e for e in drawing["entities"] if e["layerId"] == "b08-ground"] + box = entities_bbox(ground) + return box[2] - box[0] + + assert width(wide) == pytest.approx(2.0 * width(first)) + assert any("500(H) · 200(V)" in text for text in _texts(wide)) + assert any("1000(H) · 200(V)" in text for text in _texts(first)) + + +def test_횡단_X_다른_Y_덧그림_자리_도각_글(project: Path) -> None: + base = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") + _set(project, {"cross": {"x": 100, "y": 50}}) + tall = build_cross_drawing(SOURCE, "cross_00000m", DESIGN_LINE, None, None, "No.0") + placement = tall["cross_placements"][0] + assert (placement["mm_per_m_x"], placement["mm_per_m_y"]) == (10.0, 20.0) + assert placement["mm_per_m"] == 10.0 # 옛 칸 = 가로 + assert base["cross_placements"][0]["mm_per_m_y"] == 10.0 + (bx0, by0), (bx1, by1) = _ground_xy(base)[0], _ground_xy(base)[-1] + (tx0, ty0), (tx1, ty1) = _ground_xy(tall)[0], _ground_xy(tall)[-1] + assert tx1 - tx0 == pytest.approx(bx1 - bx0) # 가로 그대로 + assert ty1 - ty0 == pytest.approx(2.0 * (by1 - by0)) # 세로 2 배 + # 덧그림 식 = 서버가 그린 지반점과 같은 자리(TS `placeStructureEntities` 와 같은 식) + for sample in SOURCE["samples"][8:13]: # 그리는 창 안 측점(오프셋 -2 ~ 2 m) + x = sample["offset_m"] * placement["mm_per_m_x"] + placement["ox"] + y = (sample["elevation_m"] - placement["dy"]) * placement["mm_per_m_y"] + placement["oy"] + assert any((x, y) == pytest.approx(point) for point in _ground_xy(tall)) + sheet = plan_cross_sheets([0.0])[0] + section = {"chainage": 0.0, "source": SOURCE, "design_line": DESIGN_LINE, "title": "No.0"} + texts = _texts(build_cross_sheet(sheet, [section])) + assert any("100(H) · 50(V)" in text for text in texts) + _set(project, {"cross": {"x": 200, "y": 200}}) + texts = _texts(build_cross_sheet(sheet, [section])) + assert any(text.strip().endswith("200") and "(H)" not in text for text in texts) + + +def test_덧그림_TS_가_가로_세로_칸을_씀() -> None: + overlay = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text( + encoding="utf-8" + ) + assert "placement.mm_per_m_x ?? placement.mm_per_m" in overlay + assert "placement.mm_per_m_y ?? placement.mm_per_m" in overlay + node = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Extent_Node.ts").read_text( + encoding="utf-8" + ) + assert "input.mm_per_m_x ?? MM_PER_M" in node and "input.mm_per_m_y ?? MM_PER_M" in node + + +@pytest.mark.skipif( + shutil.which("node") is None or not EXTENT_BUNDLE.is_file(), reason="node · 번들 없음" +) +def test_덧그림_범위_Node_축척을_받음(tmp_path: Path) -> None: + detail = json.loads(FIXTURE.read_text(encoding="utf-8")) + + def extents(mm_x: float, mm_y: float) -> dict: + source, target = tmp_path / "in.json", tmp_path / "out.json" + payload = {"detail": detail, "mm_per_m_x": mm_x, "mm_per_m_y": mm_y} + source.write_text(json.dumps(payload), encoding="utf-8") + subprocess.run(["node", str(EXTENT_BUNDLE), str(source), str(target)], check=True) + rows = json.loads(target.read_text(encoding="utf-8"))["extents"] + return {row["chainage_m"]: row for row in rows} + + base, tall = extents(10.0, 10.0), extents(10.0, 20.0) + assert base and base.keys() == tall.keys() + for chainage, row in base.items(): + # 가로 축척 같음 = 가로 범위(m) 같음 · 세로 축척만 바뀜 + assert tall[chainage]["x_min"] == pytest.approx(row["x_min"]) + assert tall[chainage]["x_max"] == pytest.approx(row["x_max"]) + + +LONGITUDINAL = {"stations": [{"chainage_m": x} for x in (0.0, 20.0, 40.0)]} +MASS_HAUL = { + "points": [ + {"chainage_m": 0.0, "cumulative_volume_m3": 0.0}, + {"chainage_m": 20.0, "cumulative_volume_m3": 1000.0}, + {"chainage_m": 40.0, "cumulative_volume_m3": 400.0}, + ] +} + + +def test_토적_가로_세로(project: Path) -> None: + auto = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") + assert "SCALE H=1:500 V=1:50,000" in _texts(auto) # 40 m = 자동 1/500 + _set(project, {"mass_haul": {"x": 1000, "y": 100000}}) + fixed = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul") + assert "SCALE H=1:1,000 V=1:100,000" in _texts(fixed) + curve = next(e for e in fixed["entities"] if e["layerId"] == "b08-masshaul-curve") + end = curve["children"][0]["shapeData"]["endPoint"] + assert (end["x"], end["y"]) == pytest.approx((20.0, 10.0)) # 20 m · 1,000 ㎥ / 100 ㎥/mm + + +def test_전개도_가로_세로(project: Path) -> None: + rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(3)] + sheet = dev.development_sheets(rows)[0] + auto = build_development_drawing(sheet["id"], sheet, 20.0) + assert any(text.startswith("S=1:") for text in _texts(auto)) + _set(project, {"development": {"x": 200, "y": 100}}) + fixed = build_development_drawing(sheet["id"], sheet, 20.0) + assert "H=1:200 V=1:100" in _texts(fixed) + boxes = [e for e in fixed["entities"] if e["layerId"] == "b07-dev-box"] + box = entities_bbox(boxes[:1]) + assert (box[2] - box[0], box[3] - box[1]) == pytest.approx((6 * 5.0, 2.0 * 10.0)) + assert any("200(H) · 100(V)" in text for text in _texts(fixed)) + + +def test_범위_검사() -> None: + assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}} + for bad in ( + {"plan": {"x": 500}}, + {"standard": {"x": 50}}, + {"cross": {"x": 5}}, + {"cross": {"x": 2000}}, + {"longitudinal": {"x": 500.5}}, + {"longitudinal": {"x": True}}, + {"longitudinal": {"x": "500"}}, + {"cross": 100}, + [], + ): + with pytest.raises(ValueError): + checked_scales(bad) + + +def test_바꾼_종류만_저장본_풂(project: Path) -> None: + _write_manifest( + project, + { + "drawings": { + "longitudinal_1": {"kind": "longitudinal", "confirmed": True}, + "longitudinal_2": {"kind": "longitudinal", "confirmed": True}, + "cross_00020m": {"kind": "cross", "confirmed": True}, + "mass_haul": {"kind": "mass_haul", "confirmed": True}, + "cover": {"kind": "cover", "confirmed": True}, + } + }, + ) + assert saved_counts(project) == { + "longitudinal": 2, + "cross": 1, + "mass_haul": 1, + "development": 0, + } + # 기본과 같은 값 = 바뀐 것 없음 = 안 풂 + assert write_drawing_scales(project, project / "none.json", {"cross": {"x": 100}}) == ({}, []) + invalidated, stale = write_drawing_scales( + project, project / "none.json", {"longitudinal": {"x": 500, "y": None}} + ) + assert (invalidated, stale) == ({"longitudinal": 2}, []) + drawings = _read_manifest(project)["drawings"] + assert not drawings["longitudinal_1"]["confirmed"] + assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"] + assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200} + # 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫 + invalidated, stale = write_drawing_scales( + project, project / "none.json", {"cross": {"x": 100, "y": 50}} + ) + assert (invalidated, stale) == ({"cross": 1}, [20]) + assert read_drawing_scales(project)["longitudinal"]["x"] == 500 + + +def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + async def source(_project_id): + return 1, project, project / "none.json", False + + monkeypatch.setattr(scale_router, "_confirmed_source", source) + app = FastAPI() + app.include_router(scale_router.router) + client = TestClient(app) + url = "/api/projects/9872e9a7-e483-427b-a6b2-72cdbc063bc3/drawing-scales" + got = client.get(url).json() + assert got["scales"] == DRAWING_SCALE_DEFAULTS + assert got["ranges"]["cross"]["x"] == [10, 1000] + assert got["saved"] == {"longitudinal": 0, "cross": 0, "mass_haul": 0, "development": 0} + bad = client.put(url, json={"scales": {"cross": {"x": 5}}}) + assert bad.status_code == 400 and "1/10~1/1,000" in bad.json()["message"] + assert not drawing_scales_path(project).exists() # 거절 = 안 씀 + put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}}) + assert put.status_code == 200 + assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200} + assert put.json()["invalidated"] == {}