feat(485-1 B07 축척): 도면 X · Y 축척 서버 — 프로젝트마다 drawing_scales.json · GET/PUT /api/projects/{id}/drawing-scales · 엔진 drawing_scale(kind) 문맥 변수 · 종단 · 횡단(X ≠ Y) · 토적 · 전개도 · 바뀐 종류 저장본 풂 · 덧그림 mm_per_m_x · _y · 범위 밖 400 · 시험 test_280_5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:23:02 +09:00
co-authored by Claude Opus 5.5
parent afe6e4a799
commit 8c53124348
15 changed files with 732 additions and 58 deletions
@@ -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<DrawingScaleKind, DrawingScaleAxes>;
defaults: Record<DrawingScaleKind, DrawingScaleAxes>;
ranges: Record<DrawingScaleKind, { x: [number, number]; y: [number, number] }>;
/** 종류마다 저장본(확정) 장 수 — 바꾸면 풀리므로 화면이 먼저 물음. */
saved: Record<DrawingScaleKind, number>;
/** PUT 응답만 — 이번에 풀린 저장본 장 수. */
invalidated?: Partial<Record<DrawingScaleKind, number>>;
}
export function fetchDrawingScales(projectId: string): Promise<DrawingScales> {
return requestJson(`/projects/${projectId}/drawing-scales`);
}
/** 축척 저장 — 보낸 종류만 바꿈 · 값이 바뀐 종류의 저장본은 서버가 풂. */
export function saveDrawingScales(
projectId: string,
scales: Partial<Record<DrawingScaleKind, DrawingScaleAxes>>,
): Promise<DrawingScales> {
return requestJson(`/projects/${projectId}/drawing-scales`, {
method: "PUT",
body: JSON.stringify({ scales }),
});
}
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
export interface PlanKind {
id: string;
@@ -7,7 +7,7 @@
* 오프스크린 SVG 대신 아래 최소 DOM 위에서 돌려 측점마다 범위(m)를 낸다.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴) }
* 입력 { detail: 종횡단 상세(API와 같은 꼴), mm_per_m_x?, mm_per_m_y? } — 횡단 축척(485 · 없으면 1/100)
* 출력 { extents: [{ chainage_m, x_min, x_max, y_min, y_max }] } — 오프셋·표고(m)
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
@@ -17,7 +17,7 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
import "./B07_DesignDetail_Node_Dom"; // 최소 DOM(globalThis.document) — 그리기 코드보다 먼저
import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay";
/** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */
/** 기본 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차 · 글자 폭이 종이 mm 라 화면과 같아야 한다. */
const MM_PER_M = 10;
const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12];
@@ -81,7 +81,13 @@ if (!inputPath || !outputPath) {
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { detail?: SectionDetailResponse };
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
detail?: SectionDetailResponse;
mm_per_m_x?: number;
mm_per_m_y?: number;
};
const mmX = input.mm_per_m_x ?? MM_PER_M;
const mmY = input.mm_per_m_y ?? MM_PER_M;
const sections = input.detail?.cross_sections ?? [];
const extents: Record<string, number>[] = [];
for (const section of sections) {
@@ -90,8 +96,8 @@ for (const section of sections) {
entities = overlayEntities(
section,
sections,
(offset) => offset * MM_PER_M,
(elevation) => -(elevation * MM_PER_M),
(offset) => offset * mmX,
(elevation) => -(elevation * mmY),
OPEN_FRAME,
);
} catch (error) {
@@ -100,7 +106,8 @@ for (const section of sections) {
}
const extent = entities?.length ? extentOf(entities) : null;
if (!extent) continue;
const [xMin, xMax, yMin, yMax] = extent.map((value) => value / MM_PER_M);
const [xMin, xMax] = [extent[0] / mmX, extent[1] / mmX];
const [yMin, yMax] = [extent[2] / mmY, extent[3] / mmY];
extents.push({
chainage_m: section.chainage_m,
x_min: xMin,
@@ -560,6 +560,9 @@ export interface CrossPlacement {
oy: number;
dy: number;
mm_per_m: number;
/** 가로 · 세로 mm/m(485 · X ≠ Y 허용) — 옛 저장본엔 없음 = `mm_per_m`. */
mm_per_m_x?: number;
mm_per_m_y?: number;
x0: number;
x1: number;
/** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */
@@ -600,9 +603,10 @@ export function placeStructureEntities(
);
const frame = placement.frame;
if (!section || !frame) continue;
const x = (offset: number): number => offset * placement.mm_per_m + placement.ox;
const y = (elevation: number): number =>
-((elevation - placement.dy) * placement.mm_per_m + placement.oy);
const mmX = placement.mm_per_m_x ?? placement.mm_per_m;
const mmY = placement.mm_per_m_y ?? placement.mm_per_m;
const x = (offset: number): number => offset * mmX + placement.ox;
const y = (elevation: number): number => -((elevation - placement.dy) * mmY + placement.oy);
let entities: Entity[];
try {
const drawn = overlayEntities(section, sections, x, y, frame);
@@ -420,8 +420,22 @@ def _cross_line_entities(
return entities
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0).
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0) — 기본값 ·
# 그릴 때는 프로젝트 축척(`cross_mm`)을 쓴다(485).
CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS
def cross_mm() -> tuple[float, float]:
"""이 요청 프로젝트의 횡단 (가로, 세로) mm/m — X ≠ Y 허용(485)."""
# 도각 모듈이 이 모듈을 들여오므로 순환을 피해 함수 안에서 들여온다.
from B07_DesignDetail.B07_DesignDetail_Engine_Template import drawing_scale
scale = drawing_scale("cross")
return 1000.0 / float(scale["x"] or DRAWING_SCALE_CROSS), 1000.0 / float(
scale["y"] or DRAWING_SCALE_CROSS
)
# 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m).
CROSS_SECTION_MARGIN_M = 1.5
# 단면 블록 테두리 ↔ 안쪽 그림 간격(mm) — 한 장에 최대한 많이 담는다.
@@ -531,6 +545,7 @@ def build_cross_drawing(
cross_table_width,
)
mm_x, mm_y = cross_mm()
# 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유.
x0, x1 = _section_window(raw_ground, raw_design, overlay=overlay)
ground_clipped = _clip_polyline(raw_ground, x0, x1)
@@ -552,10 +567,10 @@ def build_cross_drawing(
+ cross_note_block_height(design, table_scale)
+ 8.0
)
oy = table_bottom + stack + own_half_height * CROSS_MM
oy = table_bottom + stack + own_half_height * mm_y
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * CROSS_MM + ox, (point[1] - dy) * CROSS_MM + oy)
return (point[0] * mm_x + ox, (point[1] - dy) * mm_y + oy)
entities: list[dict[str, Any]] = []
ground = polyline_entity(
@@ -574,8 +589,8 @@ def build_cross_drawing(
# 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면
# 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다.
center_x = ox if table_bottom is not None else ox + (x0 + x1) / 2.0 * CROSS_MM
half_height = own_half_height * CROSS_MM
center_x = ox if table_bottom is not None else ox + (x0 + x1) / 2.0 * mm_x
half_height = own_half_height * mm_y
table_bottom = oy - half_height
if quantity_table is not None:
table_top = oy - half_height - 8.0
@@ -595,8 +610,8 @@ def build_cross_drawing(
pad = CROSS_BORDER_PAD_MM
# 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1)
table_half = cross_table_width(table_scale) / 2.0
frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad
frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad
frame_left = min(ox + x0 * mm_x, center_x - table_half) - pad
frame_right = max(ox + x1 * mm_x, center_x + table_half) + pad
frame_top = oy + half_height + pad
frame_bottom = table_bottom - pad
corners = [
@@ -622,7 +637,10 @@ def build_cross_drawing(
"ox": ox,
"oy": oy,
"dy": dy,
"mm_per_m": CROSS_MM,
# 옛 칸 `mm_per_m` = 가로 · 세로가 다르면 `_x` · `_y`(485)
"mm_per_m": mm_x,
"mm_per_m_x": mm_x,
"mm_per_m_y": mm_y,
"x0": x0,
"x1": x1,
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
@@ -30,6 +30,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import station_text, table_rows
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_area,
drawing_scale,
frame_entities,
scale_fields,
)
@@ -201,7 +202,10 @@ def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, flo
def _vertical(rows: list[dict[str, Any]], mm_h: float) -> float:
"""세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게."""
"""세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게 · 정했으면 그 값(485)."""
fixed = drawing_scale("development")["y"]
if fixed:
return 1000.0 / fixed
tallest = max((r["height_m"] for r in rows if r["height_m"]), default=None)
if not tallest:
return _MIN_H
@@ -239,7 +243,12 @@ def layout(rows, area, table_box, mm_h, strict=True):
def _choose(rows, area, table_box):
"""처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고)."""
"""처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고).
가로 축척을 정했으면(485) 그 값 하나만 — 넘쳐도 그대로."""
fixed = drawing_scale("development")["x"]
if fixed:
return fixed, layout(rows, area, table_box, 1000.0 / fixed, strict=False)
for denominator in SCALES:
placed = layout(rows, area, table_box, 1000.0 / denominator)
if placed is not None:
@@ -366,6 +375,18 @@ def build_development_drawing(
table_w, table_h = sum(_COLUMNS), _ROW_H * (len(lines) + 3)
table_box = (ax1 - _MARGIN - table_w, ay0 + _MARGIN, ax1 - _MARGIN, ay0 + _MARGIN + table_h)
denominator, placed = _choose(rows, area, table_box)
vertical = drawing_scale("development")["y"]
# 세로를 정했고 가로와 다르면 이름표(H · V) — 자동 세로는 지금처럼 가로만(485)
scales = (
(("H", denominator), ("V", vertical))
if vertical and vertical != denominator
else (("", denominator),)
)
scale_text = (
f"H=1:{denominator:,} V=1:{vertical:,}"
if vertical and vertical != denominator
else f"S=1:{denominator:,}"
)
def station(chainage: float) -> str:
return station_text(chainage, interval_m)
@@ -382,7 +403,7 @@ def build_development_drawing(
),
_text_entity(
f"{drawing_id}:scale",
f"S=1:{denominator:,}",
scale_text,
ax1 - _MARGIN,
ay1 - 14.0,
LABEL_LAYER_ID,
@@ -425,7 +446,7 @@ def build_development_drawing(
area,
fit=False,
bottom_left=True,
fields={"도면명": title, **scale_fields(("", denominator))},
fields={"도면명": title, **scale_fields(*scales)},
)
return {
"format": DRAWING_FORMAT,
@@ -42,17 +42,24 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
station_no_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
entities_bbox,
frame_entities,
scale_fields,
usable_area,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,000 -> 1.0, 1/200 -> 5.0).
MM_H = 1000.0 / DRAWING_SCALE_LONG_H
MM_V = 1000.0 / DRAWING_SCALE_LONG_V
# 축척 = 이 요청 프로젝트 값(485 · 기본 1/1,000 · 1/200).
def _mm_h() -> float:
return 1000.0 / float(drawing_scale("longitudinal")["x"])
def _mm_v() -> float:
return 1000.0 / float(drawing_scale("longitudinal")["y"])
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
LONG_GRID_LAYER_ID = "b08-long-grid"
@@ -175,7 +182,7 @@ def _graph_grid_entities(
"""
# 기준선을 종이 y=0으로 두고 표고 차이만 MM_V로 올린다.
datum_y = 0.0
top_y = (top_m - datum_m) * MM_V
top_y = (top_m - datum_m) * _mm_v()
entities: list[dict[str, Any]] = [
# 기준선(X축)
_line_entity(
@@ -190,7 +197,7 @@ def _graph_grid_entities(
level_m = datum_m
tick_index = 0
while level_m <= top_m + 1e-6:
level = (level_m - datum_m) * MM_V
level = (level_m - datum_m) * _mm_v()
entities.append(
_line_entity(
f"{drawing_id}:lgyt{tick_index}",
@@ -251,8 +258,8 @@ def _curve_row_entities(
evc = curve.get("evc_m")
if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)):
continue
start = max(float(bvc) * MM_H, x0)
end = min(float(evc) * MM_H, x1)
start = max(float(bvc) * _mm_h(), x0)
end = min(float(evc) * _mm_h(), x1)
if end <= start:
continue
seed = f"{drawing_id}:lcurve:{index}"
@@ -373,8 +380,8 @@ def _grade_row_entities(
grade = segment.get("grade_percent")
if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)):
continue
start = max(float(from_m) * MM_H, x0) + radius
end = min(float(to_m) * MM_H, x1) - radius
start = max(float(from_m) * _mm_h(), x0) + radius
end = min(float(to_m) * _mm_h(), x1) - radius
if end <= start:
continue
seed = f"{drawing_id}:lgrade:{index}"
@@ -405,10 +412,10 @@ def _grade_row_entities(
# 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고
for index, (chainage, is_route_end) in enumerate(
_grade_break_points(segments, x0 / MM_H, x1 / MM_H, route_start, route_end)
_grade_break_points(segments, x0 / _mm_h(), x1 / _mm_h(), route_start, route_end)
):
# chainage는 월드 거리 — 계획고 조회에 그대로 쓰고, 좌표에는 MM_H를 곱한다.
x_paper = chainage * MM_H
x_paper = chainage * _mm_h()
seed = f"{drawing_id}:lgradebp:{index}"
if is_route_end:
entities.append(
@@ -456,8 +463,8 @@ def _long_table_entities(
route_end: float,
) -> list[dict[str, Any]]:
"""종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값."""
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
header_width = max(interval_m * MM_H, 12.0)
chainages = [float(s["chainage_m"]) * _mm_h() for s in stations]
header_width = max(interval_m * _mm_h(), 12.0)
left = chainages[0] - header_width
right = chainages[-1]
entities: list[dict[str, Any]] = []
@@ -594,7 +601,8 @@ def _long_content(
)
interval_m = infer_station_interval(all_stations)
# 좌표는 종이 mm — 거리에 MM_H, 표고는 기준선(datum) 기준으로 MM_V를 곱한다.
# 좌표는 종이 mm — 거리에 가로 mm/m, 표고는 기준선(datum) 기준으로 세로 mm/m 를 곱한다.
mm_h, mm_v = _mm_h(), _mm_v()
elevations = [y for _x, y in [*ground_points, *design_points]]
min_e = min(elevations) if elevations else 0.0
max_e = max(elevations) if elevations else 10.0
@@ -603,7 +611,7 @@ def _long_content(
top_m = max_e + 3.0
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * MM_H, (point[1] - datum_m) * MM_V)
return (point[0] * mm_h, (point[1] - datum_m) * mm_v)
entities: list[dict[str, Any]] = []
ground = polyline_entity(
@@ -618,7 +626,7 @@ def _long_content(
entities.append(design)
if stations:
chainages = [float(s["chainage_m"]) * MM_H for s in stations]
chainages = [float(s["chainage_m"]) * mm_h for s in stations]
entities.extend(
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_m, top_m)
)
@@ -647,6 +655,7 @@ def build_longitudinal_drawing(
) -> dict[str, Any]:
"""종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다."""
entities = _long_content(longitudinal, drawing_id, chunk)
scale = drawing_scale("longitudinal")
# A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다.
# 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다.
bbox = entities_bbox(entities)
@@ -659,7 +668,7 @@ def build_longitudinal_drawing(
bottom_left=True,
fields={
"도면명": "종단면도",
**scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)),
**scale_fields(("H", scale["x"]), ("V", scale["y"])),
},
)
)
@@ -36,6 +36,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
entities_bbox,
frame_entities,
scale_fields,
@@ -48,10 +49,15 @@ from config.config_system import (
)
# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). 세로는 고정이다 — 축척 분모가
# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다.
# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다. 기본값 · 그릴 때는
# 프로젝트 축척(485 · 세로 분모 50,000 = 1 mm 당 50 ㎥)을 쓴다.
MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM
def _mm_v() -> float:
return 1000.0 / float(drawing_scale("mass_haul")["y"])
def auto_scale_h(length_m: float) -> int:
"""노선 연장에 맞는 가로 축척 분모 — **한 장에 들어가는 가장 큰 그림**을 고른다.
@@ -131,7 +137,7 @@ def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]:
def _paper(x_m: float, volume_m3: float, mm_h: float) -> tuple[float, float]:
return (x_m * mm_h, volume_m3 * MM_V)
return (x_m * mm_h, volume_m3 * _mm_v())
def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float:
@@ -145,15 +151,15 @@ def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float)
def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]:
"""좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선."""
entities: list[dict[str, Any]] = []
top = max_v * MM_V
bottom = min_v * MM_V
top = max_v * _mm_v()
bottom = min_v * _mm_v()
entities.append(
_line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR)
)
start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3
value = start
while value <= max_v + 1e-6:
y = value * MM_V
y = value * _mm_v()
entities.append(
_line_entity(
f"{drawing_id}:axis:tick:{value:.0f}",
@@ -336,7 +342,7 @@ def _band_entities(
anchor = _paper((haul_from + haul_to) / 2.0, mid_level, mm_h)
top_m3 = _curve_top_at(curve, boundary_from, boundary_to)
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
center_y = top_m3 * MM_V + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER)
center_y = top_m3 * _mm_v() + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER)
entities.extend(
_balloon_entities(
drawing_id,
@@ -383,7 +389,7 @@ def _residual_entities(
]
anchor = _paper((from_m + to_m) / 2.0, level, mm_h)
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
center_y = bottom_m3 * MM_V - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER)
center_y = bottom_m3 * _mm_v() - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER)
entities.extend(
_balloon_entities(
drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect"
@@ -511,7 +517,8 @@ def build_mass_haul_drawing(
# 가로 축척은 노선 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림
# (2026-09-03 사용자 결정: 길이별 자동 축척).
length_m = max(x for x, _v in curve) - min(x for x, _v in curve)
scale_h = auto_scale_h(length_m)
scale = drawing_scale("mass_haul")
scale_h = scale["x"] or auto_scale_h(length_m) # 가로 None = 자동(485)
mm_h = 1000.0 / scale_h
entities: list[dict[str, Any]] = []
@@ -541,7 +548,7 @@ def build_mass_haul_drawing(
# 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에
# 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측).
graph_bottom = min(min_v * MM_V, 0.0)
graph_bottom = min(min_v * _mm_v(), 0.0)
drawn = entities_bbox(entities)
if drawn:
graph_bottom = min(graph_bottom, drawn[1])
@@ -565,7 +572,7 @@ def build_mass_haul_drawing(
TABLE_LABEL_COLOR,
)
)
scale_text = f"SCALE H=1:{scale_h:,} V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}"
scale_text = f"SCALE H=1:{scale_h:,} V=1:{scale['y']:,}"
entities.append(
_text_entity(
f"{drawing_id}:scale",
@@ -17,7 +17,6 @@ from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
CROSS_BORDER_PAD_MM,
CROSS_MM,
CROSS_TABLE_LAYER_ID,
DESIGN_LAYER_ID,
DRAWING_FORMAT,
@@ -30,6 +29,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_layer,
_section_window,
build_cross_drawing,
cross_mm,
points_from_samples,
polyline_entity,
)
@@ -39,11 +39,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_table_width,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scale,
frame_entities,
scale_fields,
usable_area,
)
from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
@@ -95,10 +95,11 @@ def section_block_size(
ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
if overlay is not None:
ys.extend(overlay[2:])
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM
mm_x, mm_y = cross_mm()
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * mm_y
width = 2.0 * max(
(x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM,
(x1 - x0) / 2.0 * mm_x + _BORDER_PAD_MM,
cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM,
)
# 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유
@@ -254,13 +255,20 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
if entities:
entities.extend(_grid_lines(sheet["id"], cols, rows))
scale = drawing_scale("cross")
# 가로 · 세로가 같으면 분모 하나 · 다르면 이름표(H · V)(485)
scales = (
(("", scale["x"]),)
if scale["x"] == scale["y"]
else (("H", scale["x"]), ("V", scale["y"]))
)
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3).
entities.extend(
frame_entities(
sheet["id"],
(-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0),
fit=False,
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
fields={"도면명": "횡단면도", **scale_fields(*scales)},
)
)
@@ -27,6 +27,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
FRAME_LAYER_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root
from config.config_system import (
DRAWING_SCALE_CROSS,
DRAWING_SCALE_LONG_H,
DRAWING_SCALE_LONG_V,
DRAWING_SCALE_MASSHAUL_V_M3_MM,
)
from M02_MasterTemplete.M02_Template_Layers import (
delete_template,
initial_dir,
@@ -85,6 +92,51 @@ def add_title_fields(extra: dict[str, str]) -> None:
_title_fields.set({**_title_fields.get(), **(extra or {})})
# 도면 축척(485) — 프로젝트마다 `{B07 폴더}/drawing_scales.json` `{종류: {x, y}}`(분모).
# 칸이 없거나 어긋나면 기본(`config_system`) · None = 자동(토적 가로 · 전개도 가로 · 세로).
# 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다.
DRAWING_SCALES_FILE = "drawing_scales.json"
DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = {
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V},
"cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS},
# 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥)
"mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)},
"development": {"x": None, "y": None},
}
def drawing_scales_path(project_root: Path) -> Path:
return _design_root(project_root) / DRAWING_SCALES_FILE
def read_drawing_scales(project_root: Path | None) -> dict[str, dict[str, int | None]]:
"""종류마다 {x, y} — 파일에 양의 정수가 있으면 그것, 아니면 기본."""
# ponytail: 부를 때마다 작은 파일을 읽음(수정 시각 캐시는 같은 시각 두 번 쓰기에 낡음)
stored: Any = {}
if project_root is not None:
try:
stored = json.loads(drawing_scales_path(project_root).read_text(encoding="utf-8"))
except (OSError, ValueError):
stored = {}
if not isinstance(stored, dict):
stored = {}
scales: dict[str, dict[str, int | None]] = {}
for kind, defaults in DRAWING_SCALE_DEFAULTS.items():
row = stored.get(kind) if isinstance(stored.get(kind), dict) else {}
scales[kind] = {
axis: value
if isinstance(value := row.get(axis), int) and not isinstance(value, bool) and value > 0
else default
for axis, default in defaults.items()
}
return scales
def drawing_scale(kind: str) -> dict[str, int | None]:
"""이 요청 프로젝트의 그 종류 축척 {x, y}(분모 · None = 자동)."""
return read_drawing_scales(_project_root.get())[kind]
def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path:
"""프로젝트 작업본 도각 파일 경로(없을 수도 있다)."""
return template_path(project_dir(project_root), _KIND, name)
@@ -494,7 +546,7 @@ def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]:
도각이 `A1 = 1 :` 를 이미 찍으므로 **분모만** 낸다. A3 는 A1 도면을 절반으로 뽑는
종이라 분모가 2배다. 가로·세로 축척이 다른 도면(종단·토적)은 이름표를 붙여 함께 적는다.
값은 `config_system` 의 축척 상수에서 오며 여기서 새로 정하지 않는다.
값은 엔진이 쓴 축척(`drawing_scale`)에서 오며 여기서 새로 정하지 않는다.
"""
def text(factor: int) -> str:
@@ -111,6 +111,8 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path, bool]:
longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.")
# 목록 · 확정 · 배치도 도면과 같은 도각 · 축척으로 장을 나눈다(485 — 종단 가로 = 장 나눔)
use_project_templates(root)
return route_id, root, longitudinal_path, bypassed
@@ -19,6 +19,7 @@ from typing import Any
from uuid import UUID
from B05_Profile.B05_Profile_Engine_Sections import cross_station
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import cross_mm
from common_util.common_util_node_bundle import bundle_stale, run_bundle_json
logger = logging.getLogger(__name__)
@@ -60,14 +61,16 @@ async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[i
dump = getattr(response, "model_dump", None)
if dump is None: # JSONResponse = 실패
return {}
detail = dump(mode="json")
# 종이 mm 글자 폭이 m 로 바뀌는 몫이 축척에 기댐 — 프로젝트 횡단 축척을 같이 넘김(485)
mm_x, mm_y = cross_mm()
payload = {"detail": dump(mode="json"), "mm_per_m_x": mm_x, "mm_per_m_y": mm_y}
key = hashlib.sha1(
json.dumps(detail, sort_keys=True, default=str).encode("utf-8")
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
# 번들이 낡았으면(화면 TS 가 바뀜) 기억을 버리고 다시 잰다 — 다시 빌드도 함께.
if key in _CACHE and not await asyncio.to_thread(bundle_stale, BUNDLE):
return _CACHE[key]
output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, {"detail": detail})
output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, payload)
except Exception:
logger.warning("B07 덧그림 범위 계산 실패 — 서버 그림만으로 배치", exc_info=True)
return {}
@@ -0,0 +1,191 @@
"""B07 도면 축척(485 · 요구 280-5) — 프로젝트마다 `drawing_scales.json` 읽기 · 쓰기 · GET/PUT.
엔진은 `Engine_Template.drawing_scale(kind)` 로 읽는다(문맥 변수 · 엔진 서명 그대로).
값이 바뀐 종류의 저장본(확정)은 푼다 — 축척 다른 저장본은 쓸모없음 · 종단 가로가 바뀌면
장 나눔 · 장 id 가 바뀌므로 그 종류 저장본을 모두 푼다. 화면은 `saved` 장 수로 먼저 묻는다.
"""
import asyncio
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Engine_Sections import parse_cross_key
from B06_Section.B06_Section_Repository import merge_cross_section_design_by_round
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
DRAWING_SCALE_DEFAULTS,
drawing_scales_path,
read_drawing_scales,
)
from B07_DesignDetail.B07_DesignDetail_Router import _CROSS_ID, _confirmed_source
from B07_DesignDetail.B07_DesignDetail_Router_Support import _cross_sheet_plan
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_read_json,
_read_manifest,
_write_manifest,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_workflow_state import start_stage
from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
# 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음.
SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = {
"longitudinal": {"x": (100, 10000), "y": (10, 2000)},
"cross": {"x": (10, 1000), "y": (10, 1000)},
"mass_haul": {"x": (100, 20000), "y": (1000, 1000000)},
"development": {"x": (10, 10000), "y": (10, 10000)},
}
_KIND_LABEL = {
"longitudinal": "종단도",
"cross": "횡단도",
"mass_haul": "토적도",
"development": "전개도",
}
def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]:
"""{종류: {x, y}} — 분모 정수 · 범위 안 · null = 기본(자동 칸은 자동)."""
if not isinstance(scales, dict):
raise ValueError("축척은 {종류: {x, y}} 꼴로 보내 주세요.")
checked: dict[str, dict[str, int | None]] = {}
for kind, axes in scales.items():
if kind not in SCALE_RANGES:
raise ValueError(f"축척을 바꿀 수 없는 도면입니다: {kind}")
if not isinstance(axes, dict):
raise ValueError(f"{_KIND_LABEL[kind]} 축척은 {{x, y}} 꼴로 보내 주세요.")
row: dict[str, int | None] = {}
for axis, (low, high) in SCALE_RANGES[kind].items():
value = axes.get(axis)
if value is not None and (
isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high
):
name = "X(가로)" if axis == "x" else "Y(세로)"
raise ValueError(
f"{_KIND_LABEL[kind]} {name} 축척은 1/{low:,}~1/{high:,} 사이 정수로 적으세요."
)
row[axis] = value
checked[kind] = row
return checked
def saved_counts(project_root: Path) -> dict[str, int]:
"""종류마다 저장본(확정) 장 수."""
counts = dict.fromkeys(SCALE_RANGES, 0)
for entry in _read_manifest(project_root)["drawings"].values():
if entry.get("confirmed") and entry.get("kind") in counts:
counts[entry["kind"]] += 1
return counts
def scales_payload(project_root: Path) -> dict[str, Any]:
return {
"scales": read_drawing_scales(project_root),
"defaults": DRAWING_SCALE_DEFAULTS,
"ranges": {
kind: {axis: list(bounds) for axis, bounds in axes.items()}
for kind, axes in SCALE_RANGES.items()
},
"saved": saved_counts(project_root),
}
def write_drawing_scales(
project_root: Path, longitudinal_path: Path, scales: Any
) -> tuple[dict[str, int], list[int | float]]:
"""보낸 종류만 바꿔 쓰고 값이 바뀐 종류의 저장본을 푼다 — (풀린 장 수, 풀린 횡단 측점)."""
checked = checked_scales(scales)
before = read_drawing_scales(project_root)
path = drawing_scales_path(project_root)
try:
stored = _read_json(path) if path.is_file() else {}
except ValueError:
stored = {} # 깨진 파일 = 기본 — 새 값으로 덮음
atomic_write_json(path, {**stored, **checked})
after = read_drawing_scales(project_root)
changed = {kind for kind in checked if after[kind] != before[kind]}
invalidated: dict[str, int] = {}
stale_ids: list[str] = []
manifest = _read_manifest(project_root)
for drawing_id, entry in manifest["drawings"].items():
kind = entry.get("kind")
if entry.get("confirmed") and kind in changed:
entry["confirmed"] = False
invalidated[kind] = invalidated.get(kind, 0) + 1
if kind == "cross":
stale_ids.append(drawing_id)
if invalidated:
_write_manifest(project_root, manifest)
# 풀린 횡단 장 · 측점 도면에 담긴 측점 — 설계 상태를 잠정으로 되돌릴 몫(배치 바꿈과 같은 꼴)
stale: list[int | float] = []
sheets: dict[str, list[int | float]] | None = None
for drawing_id in stale_ids:
if match := _CROSS_ID.fullmatch(drawing_id):
stale.append(parse_cross_key(match.group(1)))
continue
if sheets is None: # 장 계획은 장이 풀릴 때만 셈
plan = _cross_sheet_plan(project_root, longitudinal_path)
sheets = {sheet["id"]: sheet["chainages"] for sheet in plan}
stale.extend(sheets.get(drawing_id, []))
return invalidated, stale
class DrawingScalesRequest(BaseModel):
"""바꿀 종류만 — {종류: {x, y}} · 분모 정수 · null = 기본(자동)."""
scales: dict[str, Any] = Field(default_factory=dict)
@router.get("/{project_id}/drawing-scales")
async def get_drawing_scales(project_id: UUID) -> Any:
"""도면 축척 — 파일 없으면 기본 · 종류마다 저장본 장 수(`saved`)."""
try:
_route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id)
return await asyncio.to_thread(scales_payload, project_root)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
@router.put("/{project_id}/drawing-scales")
async def put_drawing_scales(project_id: UUID, request: DrawingScalesRequest) -> Any:
"""축척을 쓴다 — 값이 바뀐 종류의 저장본은 풀고 횡단이면 담긴 측점 설계를 잠정으로."""
try:
route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id)
invalidated, stale = await asyncio.to_thread(
write_drawing_scales, project_root, longitudinal_path, request.scales
)
if invalidated:
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage=chainage,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 4)
await connection.commit()
except Exception:
await connection.rollback()
raise
payload = await asyncio.to_thread(scales_payload, project_root)
return {**payload, "invalidated": invalidated}
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
@@ -8,7 +8,7 @@
* 앞으로 B06에 구조물·치수·글자가 붙으면 여기 손대지 않아도 도면에 따라온다.
*
* 자리 맞추기는 서버가 도면에 실어 보내는 `cross_placements`가 정한다:
* x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy
* x_mm = offset*mm_per_m_x + ox, y_mm = (elev - dy)*mm_per_m_y + oy (없으면 mm_per_m · 485)
* SVG는 y가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다.
* ========================================================================== */
+2
View File
@@ -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)
@@ -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"] == {}