diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index aa12da9dc..6248f2fd6 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -182,6 +182,8 @@ export interface PlanKind { pieces: string[]; /** 양식 — plan(평면도 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). */ layout: PlanKindLayout; + /** 축척 분모 — 평면도 양식은 `scales` 중 하나(기본 1,200) · 유역도는 6,000 고정. */ + scale: number; } export type PlanKindLayout = "plan" | "watershed"; export interface PlanMark { @@ -191,6 +193,10 @@ export interface PlanMark { } export interface PlanKindsState { pieces: { id: string; name: string }[]; + /** 고를 수 있는 평면도 축척 분모(서버 `DRAWING_SCALE_PLAN_CHOICES`) · 기본 · 유역도 고정. */ + scales: number[]; + default_scale: number; + watershed_scale: number; kinds: PlanKind[]; marks: PlanMark[]; /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ @@ -206,10 +212,11 @@ export function addPlanKind( name: string, pieces: string[], layout: PlanKindLayout = "plan", + scale?: number, ): Promise { return requestJson(`/projects/${projectId}/plan-kinds`, { method: "POST", - body: JSON.stringify({ name, pieces, layout }), + body: JSON.stringify({ name, pieces, layout, scale }), }); } @@ -218,10 +225,11 @@ export function updatePlanKind( kindId: string, name: string, pieces: string[], + scale?: number, ): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "PUT", - body: JSON.stringify({ name, pieces }), + body: JSON.stringify({ name, pieces, scale }), }); } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py index 78d718bd3..93f833552 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py @@ -153,16 +153,18 @@ def clip_lines( pieces[piece]["lines"] = kept -def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: +def _clip( + pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any, mm: float = MM +) -> None: """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" min_x, min_y = box[0], box[1] - width = sum(_COLUMNS) / MM + width = sum(_COLUMNS) / mm rects = [ ( - min_x + b["origin"][0] / MM, - min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, - min_x + b["origin"][0] / MM + width, - min_y + b["origin"][1] / MM, + min_x + b["origin"][0] / mm, + min_y + b["origin"][1] / mm - b["rows"] * _ROW_H / mm, + min_x + b["origin"][0] / mm + width, + min_y + b["origin"][1] / mm, ) for b in blocks ] @@ -176,6 +178,7 @@ def ip_table_piece( box: tuple[float, float, float, float], chunk: dict[str, Any], total: int, + mm: float = MM, ) -> dict[str, Any]: """그 장 IP 표 조각 {blocks} — 자리를 정하고 같은 자리 밑 선 조각을 `pieces` 에서 잘라 낸다.""" blocks = ip_blocks( @@ -186,9 +189,9 @@ def ip_table_piece( float(chunk["start_m"]), float(chunk["end_m"]), ) - _place(blocks, (box[2] - box[0]) * MM) + _place(blocks, (box[2] - box[0]) * mm) if blocks: - _clip(pieces, blocks, box) + _clip(pieces, blocks, box, mm) return {"blocks": blocks} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py index 18da7be67..51c6690eb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py @@ -87,13 +87,15 @@ def plan_area_mm() -> tuple[float, float]: return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND) -def _chunk_span_m() -> tuple[float, float]: +def _chunk_span_m(scale: int = DRAWING_SCALE_PLAN) -> tuple[float, float]: """한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기.""" area_w, area_h = plan_area_mm() - return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0) + return (area_w * scale / 1000.0, area_h * scale / 1000.0) -def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]: +def plan_chunks( + stations: list[tuple[float, float, float]], scale: int = DRAWING_SCALE_PLAN +) -> list[dict[str, Any]]: """노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}. 입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를 @@ -107,7 +109,7 @@ def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, An if len(ordered) < 2: span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0) return [{"number": 1, "start_m": span[0], "end_m": span[1]}] - span_w, span_h = _chunk_span_m() + span_w, span_h = _chunk_span_m(scale) def fits(part: list[tuple[float, float, float]]) -> bool: width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 072a4dc33..878f9515d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -6,7 +6,7 @@ 있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). -좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x (1000 / 종류 축척 분모) (기본 1/1,200). """ import math @@ -372,7 +372,7 @@ def _reading(nx: float, ny: float) -> tuple[float, float]: def _piece_entities( - drawing_id: str, piece: str, data: dict[str, Any], paper: Any + drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM ) -> list[dict[str, Any]]: layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) out: list[dict[str, Any]] = [] @@ -446,8 +446,8 @@ def _piece_entities( nx, ny = item["nx"], item["ny"] line( [ - (item["x"] - nx * _TICK_MM / MM, item["y"] - ny * _TICK_MM / MM), - (item["x"] + nx * _TICK_MM / MM, item["y"] + ny * _TICK_MM / MM), + (item["x"] - nx * _TICK_MM / mm, item["y"] - ny * _TICK_MM / mm), + (item["x"] + nx * _TICK_MM / mm, item["y"] + ny * _TICK_MM / mm), ], f":{index}", ) @@ -468,7 +468,7 @@ def _piece_entities( elif piece == "endpoints": for index, item in enumerate(data.get("items") or []): nx, ny = item["nx"], item["ny"] - reach = 6.0 / MM # 종이 6mm 막대 + reach = 6.0 / mm # 종이 6mm 막대 line( [ (item["x"] - nx * reach, item["y"] - ny * reach), @@ -482,7 +482,7 @@ def _piece_entities( elif piece == "refuge": size = 2.0 # 네모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): - x, y, half = item["x"], item["y"], size / MM + x, y, half = item["x"], item["y"], size / mm corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)] line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) @@ -531,19 +531,21 @@ def build_plan_kind_drawing( label: str, box: tuple[float, float, float, float], pieces: dict[str, dict[str, Any]], + scale: int = DRAWING_SCALE_PLAN, ) -> dict[str, Any]: """고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m).""" min_x, min_y = box[0], box[1] + mm = 1000.0 / scale def paper(point: Any) -> tuple[float, float]: - return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + return ((point[0] - min_x) * mm, (point[1] - min_y) * mm) entities: list[dict[str, Any]] = [] for piece in PIECE_IDS: if piece in pieces: - entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper)) + entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper, mm)) # 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다. - frame_box = (0.0, 0.0, (box[2] - min_x) * MM, (box[3] - min_y) * MM) + frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm) entities.extend( compass_entities( drawing_id, @@ -570,7 +572,7 @@ def build_plan_kind_drawing( entities.append( _text_entity( f"{drawing_id}:scale", - f"S = 1/{DRAWING_SCALE_PLAN:,}", + f"S = 1/{scale:,}", max_bx, max_by + 5.0, "b07-plan-title", @@ -584,7 +586,7 @@ def build_plan_kind_drawing( drawing_id, entities_bbox(entities) or bbox, fit=False, - fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + fields={"도면명": label, **scale_fields(("", scale))}, ) ) layers = [ diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index 10e823bf9..a12340f21 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -26,6 +26,11 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( update_kind, write_marks, ) +from config.config_system import ( + DRAWING_SCALE_BASIN, + DRAWING_SCALE_PLAN, + DRAWING_SCALE_PLAN_CHOICES, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) @@ -36,6 +41,8 @@ class PlanKindRequest(BaseModel): pieces: list[str] = Field(default_factory=list) # 양식 — plan(평면도 · 조각 고름) · watershed(유역도). 추가에서만 쓰고 업데이트는 그대로 둔다. layout: str = "plan" + # 축척 분모(`scales` 중 하나) — 없으면 추가는 1/1,200 · 업데이트는 지금 값 · 유역도 1/6,000. + scale: int | None = None class PlanMark(BaseModel): @@ -52,6 +59,9 @@ def _state(root: Any) -> dict[str, Any]: return { "status": "success", "pieces": [{"id": piece, "name": name} for piece, name, _locked in PIECES], + "scales": list(DRAWING_SCALE_PLAN_CHOICES), + "default_scale": DRAWING_SCALE_PLAN, + "watershed_scale": DRAWING_SCALE_BASIN, "kinds": list_kinds(root), "marks": read_marks(root), } @@ -84,7 +94,9 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: """종류 추가 — 양식 · 조각을 골라 이름을 붙인다.""" try: root = await _project_root(project_id) - await asyncio.to_thread(add_kind, root, request.name, request.pieces, request.layout) + await asyncio.to_thread( + add_kind, root, request.name, request.pieces, request.layout, request.scale + ) return await asyncio.to_thread(_state, root) except ValueError as exc: return _error(400, exc) @@ -95,7 +107,9 @@ async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" try: root = await _project_root(project_id) - await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces) + await asyncio.to_thread( + update_kind, root, kind_id, request.name, request.pieces, request.scale + ) return await asyncio.to_thread(_state, root) except ValueError as exc: return _error(400, exc) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 55d141e11..1ddcb240a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -83,6 +83,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal +from config.config_system import DRAWING_SCALE_PLAN logger = logging.getLogger(__name__) @@ -457,7 +458,11 @@ def _read_drawing( ) else: drawing = build_plan_kind_drawing( - drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} + drawing_id, + label, + stored_design["box"], + stored_design.get("pieces") or {}, + stored_design.get("scale") or DRAWING_SCALE_PLAN, ) return "plan", label, drawing, False, None diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index f55535be1..4419556fd 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -379,12 +379,12 @@ LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$") def _sheet_box( - route_xy: list[tuple[float, float]], + route_xy: list[tuple[float, float]], scale: int = DRAWING_SCALE_PLAN ) -> tuple[float, float, float, float]: """그 장의 도곽 범위(실좌표 m) — 계획평면도·용지도·라이다가 같은 규칙을 쓴다.""" area_w_mm, area_h_mm = plan_area_mm() - half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 - half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_w = area_w_mm / 2.0 * scale / 1000.0 + half_h = area_h_mm / 2.0 * scale / 1000.0 center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 return ( @@ -396,10 +396,10 @@ def _sheet_box( def _chunk_route( - context: Any, longitudinal: dict[str, Any], number: int + context: Any, longitudinal: dict[str, Any], number: int, scale: int = DRAWING_SCALE_PLAN ) -> tuple[list[tuple[float, float]], dict[str, Any], int]: """장 번호로 그 장의 노선 구간을 잘라 낸다 (계획평면도 장 나눔과 같은 기준).""" - chunks = plan_chunks(plan_stations(longitudinal)) + chunks = plan_chunks(plan_stations(longitudinal), scale) chunk = next((item for item in chunks if item["number"] == number), None) if chunk is None: raise FileNotFoundError("요청한 장을 찾을 수 없습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py index 879af2b05..16463c373 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -128,6 +128,7 @@ def place_labels( parcels: list[dict[str, Any]], route_xy: list[tuple[float, float]], box: tuple[float, float, float, float], + mm: float = MM, ) -> list[dict[str, Any]]: """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() @@ -142,7 +143,7 @@ def place_labels( if not any(lines): continue width_mm, height_mm = balloon_size(lines, PARCEL_FONT) - half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + half_w, half_h = width_mm / mm / 2.0, height_mm / mm / 2.0 ax, ay = _anchor(parcel["area"], clear) free_of_route: tuple[float, float, float, float] | None = None chosen = None @@ -189,12 +190,14 @@ def place_labels( return items -def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]: +def label_rects( + items: list[dict[str, Any]], mm: float = MM +) -> list[tuple[float, float, float, float]]: """필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기.""" rects = [] for item in items: width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT) - half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + half_w, half_h = width_mm / mm / 2.0, height_mm / mm / 2.0 cx, cy = item["center"] rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h)) return rects @@ -205,13 +208,14 @@ def landuse_pieces( crs: str, box: tuple[float, float, float, float], route_xy: list[tuple[float, float]], + mm: float = MM, ) -> dict[str, dict[str, Any]]: """그 장의 용지 조각 넷 — 노선은 라벨이 비켜 갈 전체 중심선(사업지 m).""" folder = project_root / "B04_PreProcess" / "processed" parcels = clip_parcels(folder / PARCEL_FILE, crs, box) return { "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, - "parcel_info": {"items": place_labels(parcels, route_xy, box)}, + "parcel_info": {"items": place_labels(parcels, route_xy, box, mm)}, "emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)}, "sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)}, } diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1cfe716d7..97410d0a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -1,7 +1,7 @@ """B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장). 저장 자리(프로젝트 저장소 `B07_DesignDetail/`): - plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout} — 파일 하나씩 + plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout, scale} — 파일 하나씩 plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) plan_marks.json 사용자가 정한 대피소 · 사토장 자리 @@ -65,7 +65,11 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( ) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal -from config.config_system import DRAWING_SCALE_PLAN +from config.config_system import ( + DRAWING_SCALE_BASIN, + DRAWING_SCALE_PLAN, + DRAWING_SCALE_PLAN_CHOICES, +) KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$") PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$") @@ -91,12 +95,21 @@ def _kinds_dir(project_root: Path) -> Path: _ADDED_ONCE = ("landuse", "watershed") +def _scale(layout: str, value: Any) -> int: + """종류 축척 분모 — 유역도 양식은 1/6,000 고정 · 없거나 목록 밖이면 1/1,200(옛 파일).""" + if layout == WATERSHED_LAYOUT: + return DRAWING_SCALE_BASIN + return value if value in DRAWING_SCALE_PLAN_CHOICES else DRAWING_SCALE_PLAN + + def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: + layout = kind.get("layout", "plan") return { "name": kind["name"], "pieces": list(kind["pieces"]), "order": order, - "layout": kind.get("layout", "plan"), + "layout": layout, + "scale": _scale(layout, kind.get("scale")), } @@ -122,11 +135,17 @@ def _add_defaults_once(folder: Path) -> None: def list_kinds(project_root: Path) -> list[dict[str, Any]]: - """종류 목록 [{id, name, pieces, layout}] — 차례대로. 파일이 없으면 기본 종류.""" + """종류 목록 [{id, name, pieces, layout, scale}] — 차례대로. 파일이 없으면 기본 종류.""" folder = _kinds_dir(project_root) if not folder.is_dir(): return [ - {"layout": "plan", **kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS + { + "layout": "plan", + **kind, + "pieces": list(kind["pieces"]), + "scale": _scale(kind.get("layout", "plan"), None), + } + for kind in DEFAULT_KINDS ] _add_defaults_once(folder) rows: list[tuple[int, str, dict[str, Any]]] = [] @@ -151,6 +170,7 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: "name": str(document.get("name") or path.stem), "pieces": pieces, "layout": layout, + "scale": _scale(layout, document.get("scale")), }, ) ) @@ -183,20 +203,44 @@ def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, l return text, chosen +def _checked_scale(layout: str, scale: int | None, current: int = DRAWING_SCALE_PLAN) -> int: + """고른 축척 — 없으면 지금 값 · 목록 밖이면 거절 · 유역도 양식은 1/6,000 고정.""" + if layout == WATERSHED_LAYOUT: + return DRAWING_SCALE_BASIN + if scale is None: + return current + if scale not in DRAWING_SCALE_PLAN_CHOICES: + raise ValueError("고를 수 없는 축척입니다.") + return scale + + def add_kind( - project_root: Path, name: str, pieces: list[str], layout: str = "plan" + project_root: Path, + name: str, + pieces: list[str], + layout: str = "plan", + scale: int | None = None, ) -> dict[str, Any]: text, chosen = _checked(name, pieces, layout) + chosen_scale = _checked_scale(layout, scale) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") - kind = {"id": f"k{uuid.uuid4().hex[:8]}", "name": text, "pieces": chosen, "layout": layout} + kind = { + "id": f"k{uuid.uuid4().hex[:8]}", + "name": text, + "pieces": chosen, + "layout": layout, + "scale": chosen_scale, + } atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds))) return kind -def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: - """종류 이름 · 조각을 고친다(차례 · 양식은 그대로). 그 종류의 확정본은 풀어 다시 그린다.""" +def update_kind( + project_root: Path, kind_id: str, name: str, pieces: list[str], scale: int | None = None +) -> dict[str, Any]: + """종류 이름 · 조각 · 축척을 고친다(차례 · 양식은 그대로). 그 종류 확정본은 풀어 다시 그린다.""" if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") kinds = _materialize(project_root) @@ -205,10 +249,17 @@ def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) if current is None or not path.is_file(): raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") text, chosen = _checked(name, pieces, current["layout"]) + chosen_scale = _checked_scale(current["layout"], scale, current["scale"]) if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") order = int(_read_json(path).get("order") or 0) - kind = {"id": kind_id, "name": text, "pieces": chosen, "layout": current["layout"]} + kind = { + "id": kind_id, + "name": text, + "pieces": chosen, + "layout": current["layout"], + "scale": chosen_scale, + } atomic_write_json(path, _document(kind, order)) manifest = _read_manifest(project_root) released = False @@ -383,12 +434,14 @@ def build_pieces( designs: dict[int | float, dict[str, Any]], number: int, wanted: list[str], + scale: int = DRAWING_SCALE_PLAN, ) -> tuple[dict[str, dict[str, Any]], tuple[float, float, float, float], dict[str, Any], int]: """그 장의 조각들을 계산해 파일로 쓰고 (조각들, 도곽 범위, 장 구간, 전체 장수)를 낸다.""" - route_xy, chunk, total = _chunk_route(context, longitudinal, number) + route_xy, chunk, total = _chunk_route(context, longitudinal, number, scale) if not route_xy: raise FileNotFoundError("계획평면도에 그릴 노선이 없습니다.") - box = _sheet_box(route_xy) + box = _sheet_box(route_xy, scale) + mm = 1000.0 / scale start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) project_root = Path(context.project_root) route = sorted( @@ -399,9 +452,7 @@ def build_pieces( pieces: dict[str, dict[str, Any]] = {} if {"contour", "stream"} & set(wanted): - background = map_background( - project_root, context.crs, DRAWING_SCALE_PLAN, plan_area_mm(), route_xy - ) + background = map_background(project_root, context.crs, scale, plan_area_mm(), route_xy) pieces["contour"] = {"lines": background["contours"]} pieces["stream"] = {"lines": background["streams"]} if "centerline" in wanted: @@ -473,13 +524,13 @@ def build_pieces( if set(LANDUSE_PIECES) & set(wanted): # 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다. pieces.update( - landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route]) + landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route], mm) ) if "parcel_info" in wanted: - clip_lines(pieces, label_rects(pieces["parcel_info"]["items"]), LABEL_CLIPPED) + clip_lines(pieces, label_rects(pieces["parcel_info"]["items"], mm), LABEL_CLIPPED) if "ip_table" in wanted: pieces["ip_table"] = ip_table_piece( - pieces, _load_design_curves(project_root), route, box, chunk, total + pieces, _load_design_curves(project_root), route, box, chunk, total, mm ) folder = _design_root(project_root) / "plan_pieces" / str(number) @@ -510,19 +561,21 @@ def plan_kind_source( return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)} number = int(match.group(2)) if match.group(2) else 1 pieces, box, chunk, total = build_pieces( - context, longitudinal, longitudinal_path, designs, number, kind["pieces"] + context, longitudinal, longitudinal_path, designs, number, kind["pieces"], kind["scale"] ) title = plan_title(kind["name"]) return { "label": title if total <= 1 else f"{title} {chunk['number']}장", "box": box, "pieces": pieces, + "scale": kind["scale"], } def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[dict[str, Any]]: """도면 목록에 실을 종류 × 장 [{id, label, plan_kind}].""" - sheets = plan_chunks(plan_stations(longitudinal)) + stations = plan_stations(longitudinal) + by_scale: dict[int, list[dict[str, Any]]] = {} items = [] for kind in list_kinds(project_root): if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음 @@ -536,6 +589,9 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di ) continue title = plan_title(kind["name"]) + if kind["scale"] not in by_scale: + by_scale[kind["scale"]] = plan_chunks(stations, kind["scale"]) + sheets = by_scale[kind["scale"]] for chunk in sheets: single = len(sheets) <= 1 items.append( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 2af96dcbe..b36029d59 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -52,7 +52,7 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ +/** 양식 — 평면도(조각 고름 · 축척 고름 · 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ { value: "plan", text: "평면도" }, { value: "watershed", text: "유역도" }, @@ -77,6 +77,9 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme ariaLabel: "양식", onChange: () => showPieces(), }); + // 축척 — 목록은 서버 `scales` 한 곳 · 유역도 양식은 1/6,000 고정(잠금). + const scaleText = (value: number): string => `1/${value.toLocaleString("en-US")}`; + const scale = createSelectField({ options: [], compact: true, ariaLabel: "축척" }); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; @@ -88,7 +91,22 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme action.type = "button"; // 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다. const showPieces = (): void => { - pieceList.hidden = layout.select.value === "watershed"; + const watershed = layout.select.value === "watershed"; + pieceList.hidden = watershed; + const kind = state.kinds.find((item) => item.id === pick.select.value); + if (watershed) { + const fixed = String(state.watershed_scale); + scale.setOptions( + [{ value: fixed, text: `${scaleText(state.watershed_scale)} (고정)` }], + fixed, + ); + } else { + scale.setOptions( + state.scales.map((value) => ({ value: String(value), text: scaleText(value) })), + String(kind?.layout === "plan" ? kind.scale : state.default_scale), + ); + } + scale.select.disabled = watershed; }; const show = (): void => { const kind = state.kinds.find((item) => item.id === pick.select.value); @@ -106,15 +124,16 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme const kindId = pick.select.value; const label = name.value.trim(); const chosen = layout.select.value as PlanKindLayout; + const denominator = Number(scale.select.value); void run( () => kindId - ? updatePlanKind(controls.projectId, kindId, label, pieces) - : addPlanKind(controls.projectId, label, pieces, chosen), + ? updatePlanKind(controls.projectId, kindId, label, pieces, denominator) + : addPlanKind(controls.projectId, label, pieces, chosen, denominator), controls, ); }); - form.append(pick.root, layout.root, name, pieceList, action); + form.append(pick.root, layout.root, scale.root, name, pieceList, action); return form; } diff --git a/config/config_system.py b/config/config_system.py index 98ef486f7..24de90716 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -185,6 +185,8 @@ DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/ # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 +# 평면도 종류마다 고를 수 있는 축척 분모 — 1/1,200 기본(법정) · 1/1,000 · 1/600(용지도). +DRAWING_SCALE_PLAN_CHOICES = (600, 1000, 1200) # 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도 # 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다. DRAWING_SCALE_CROSS_STANDARD = 50 diff --git a/resources/tester/test_119_b07_plan_scale.py b/resources/tester/test_119_b07_plan_scale.py new file mode 100644 index 000000000..9e448c718 --- /dev/null +++ b/resources/tester/test_119_b07_plan_scale.py @@ -0,0 +1,173 @@ +"""B07 평면도 종류마다 축척 고르기 (PLAN 119장). + +① 축척 목록 1/600 · 1/1,000 · 1/1,200(기본) — 추가 · 업데이트 · 다시 읽어 같음 · 목록 밖 거절 · + 옛 파일(축척 없음 · 목록 밖) = 1/1,200 · 유역도 양식 = 1/6,000 고정 +② 1/600 → 장 수 늚 · 1/1,200 = 지금과 같음(기본 인자 장 나눔) +③ 그림 — 축척 글 「S = 1/600」 · 도곽 축척 칸 600 · A3 1200 · 1/1,200 은 기본 인자와 같은 그림 +④ 종이 mm → 실거리 환산이 축척 따라감 — 측점 눈금 · 시종점 막대 종이 길이 그대로 · + IP 표 밑 자름 폭 · 필지 라벨 상자 실거리 절반 +""" + +import json +import math +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import _COLUMNS, _ROW_H, ip_table_piece +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + build_plan_kind_drawing, + layer_id, +) +from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import _state +from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import label_rects +from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + _kinds_dir, + add_kind, + list_kinds, + plan_kind_items, + update_kind, +) + +_STATIONS = [ + {"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20) +] + + +def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None: + root = tmp_path / "project" + state = _state(root) + assert state["scales"] == [600, 1000, 1200] and state["default_scale"] == 1200 + assert all(k["scale"] == (6000 if k["layout"] == "watershed" else 1200) for k in state["kinds"]) + added = add_kind(root, "용지 600", ["contour"], "plan", 600) + assert added["scale"] == 600 + assert next(k for k in list_kinds(root) if k["id"] == added["id"]) == added + assert add_kind(root, "기본 축척", ["contour"])["scale"] == 1200 + update_kind(root, added["id"], "용지 1000", ["contour"], 1000) + assert next(k for k in list_kinds(root) if k["id"] == added["id"])["scale"] == 1000 + update_kind(root, added["id"], "용지 1000", ["contour"]) # 축척 없이 = 지금 값 + assert next(k for k in list_kinds(root) if k["id"] == added["id"])["scale"] == 1000 + with pytest.raises(ValueError): + add_kind(root, "이상한 축척", ["contour"], "plan", 500) + with pytest.raises(ValueError): + update_kind(root, added["id"], "용지 1000", ["contour"], 777) + watershed = add_kind(root, "유역 둘", [], "watershed", 600) # 유역도 양식은 고정 + assert watershed["scale"] == 6000 + folder = _kinds_dir(root) + for stem, extra in (("old", {}), ("odd", {"scale": 777})): + (folder / f"{stem}.json").write_text( + json.dumps({"name": stem, "pieces": ["contour"], "order": 99, **extra}), "utf-8" + ) + kinds = {k["id"]: k for k in list_kinds(root)} + assert kinds["old"]["scale"] == 1200 and kinds["odd"]["scale"] == 1200 + + +def test_sheet_count_follows_scale(tmp_path: Path) -> None: + root = tmp_path / "project" + add_kind(root, "용지 600", ["contour"], "plan", 600) + items = plan_kind_items(root, {"stations": _STATIONS}) + big = [item for item in items if item["plan_kind_name"] == "용지 600"] + terrain = [item for item in items if item["plan_kind"] == "terrain"] + stations = [(s["chainage_m"], s["center_x"], s["center_y"]) for s in _STATIONS] + assert len(terrain) == len(plan_chunks(stations)) == len(plan_chunks(stations, 1200)) + assert len(big) == len(plan_chunks(stations, 600)) > len(terrain) + assert big[-1]["label"].endswith(f"{len(big)}장") + + +def _texts(drawing: dict) -> str: + return json.dumps(drawing["entities"], ensure_ascii=False) + + +def _frame_labels(drawing: dict) -> set[str]: + return { + (e.get("shapeData") or {}).get("label") + for e in drawing["entities"] + if e.get("layerId") == "b08-frame" and e["type"] == "Text" + } + + +def _segment(entity: dict) -> tuple[float, float, float]: + """두 점 PolyLine(자식 Line 하나) — (길이, 가운데 x, 가운데 y) 종이 mm.""" + (child,) = entity["children"] + a, b = child["shapeData"]["startPoint"], child["shapeData"]["endPoint"] + return ( + math.hypot(b["x"] - a["x"], b["y"] - a["y"]), + (a["x"] + b["x"]) / 2.0, + (a["y"] + b["y"]) / 2.0, + ) + + +def test_drawing_scale_text_frame_and_paper_lengths() -> None: + box = (0.0, 0.0, 400.0, 300.0) + pieces = { + "station": { + "items": [ + {"x": 100.0, "y": 100.0, "nx": 1.0, "ny": 0.0, "label": "NO.5", "major": True} + ] + }, + "endpoints": {"items": [{"label": "시점", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}]}, + } + base = build_plan_kind_drawing("plan_x", "시험", box, pieces) + assert build_plan_kind_drawing("plan_x", "시험", box, pieces, 1200) == base + small = build_plan_kind_drawing("plan_x", "시험", box, pieces, 600) + assert "S = 1/1,200" in _texts(base) and "S = 1/600" in _texts(small) + assert "S = 1/1,200" not in _texts(small) + scales = [ + e + for e in small["entities"] + if str((e.get("shapeData") or {}).get("label")).startswith("S = ") + ] + assert [e["shapeData"]["label"] for e in scales] == ["S = 1/600"] + # 도곽 축척 칸 — A1 분모 600 · A3 1200 (1/1,200 그림은 1200 · 2400) + assert {"600", "1200"} <= _frame_labels(small) and "2400" not in _frame_labels(small) + assert {"1200", "2400"} <= _frame_labels(base) and "600" not in _frame_labels(base) + # 종이 mm 기호는 축척과 무관하게 같은 길이 · 실거리 자리는 축척만큼 멀어짐 + for piece, paper_mm in (("station", 3.0), ("endpoints", 12.0)): + lines = [ + [ + e + for e in d["entities"] + if e.get("layerId") == layer_id(piece) and e["type"] != "Text" + ] + for d in (base, small) + ] + (length, x, y), (length_s, x_s, y_s) = _segment(lines[0][0]), _segment(lines[1][0]) + assert length == pytest.approx(paper_mm) and length_s == pytest.approx(paper_mm) + assert (x_s, y_s) == (pytest.approx(2 * x), pytest.approx(2 * y)) # 실거리 자리는 2배 + + +# test_b07_plan_kinds 의 IP 표 시험과 같은 노선 · 곡선 +_ROUTE = [(0.0, 0.0, 0.0), (85.0, 85.0, 0.0), (115.0, 100.0, 15.0), (200.0, 100.0, 100.0)] +_CURVES = [ + {"apex": [500.0, 500.0], "start": [490.0, 500.0], "end": [500.0, 510.0], "radius_m": 10.0}, + {"apex": [100.0, 0.0], "start": [85.0, 0.0], "end": [100.0, 15.0], "radius_m": 15.0}, +] + + +def _gap(mm: float) -> tuple[float, int]: + """y = 0 등고선에서 잘려 나간 길이(m) · 그 줄을 덮는 표 수.""" + box = (-20.0, -20.0, 300.0, 200.0) + pieces = {"contour": {"lines": [[[-20.0, 0.0], [300.0, 0.0]]]}} + table = ip_table_piece( + pieces, _CURVES, _ROUTE, box, {"number": 1, "start_m": 0.0, "end_m": 200.0}, 1, mm + ) + kept = sum(line[-1][0] - line[0][0] for line in pieces["contour"]["lines"]) + covering = sum( + 1 + for b in table["blocks"] + if box[1] + (b["origin"][1] - b["rows"] * _ROW_H) / mm < 0.0 < box[1] + b["origin"][1] / mm + ) + return 320.0 - kept, covering + + +def test_ip_table_clip_and_parcel_label_follow_scale() -> None: + assert _gap(1000.0 / 1200) == (pytest.approx(43.2), 1) # 지금 그림(test_b07_plan_kinds)과 같음 + # 표 종이 폭 36 mm 그대로 → 1/600 실거리 21.6 m 씩(덮는 표마다) + removed, covering = _gap(1000.0 / 600) + assert covering >= 1 and removed == pytest.approx(sum(_COLUMNS) * 0.6 * covering) + items = [{"lines": ["1-1", "임야"], "center": [0.0, 0.0]}] + (big,) = label_rects(items) + (half,) = label_rects(items, 1000.0 / 600) + assert half[2] - half[0] == pytest.approx((big[2] - big[0]) / 2) + assert half[3] - half[1] == pytest.approx((big[3] - big[1]) / 2) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index 3df0d6ff8..ca84bd61c 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -247,6 +247,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: "name": "지형2", "pieces": ["contour", "centerline"], "layout": "plan", + "scale": 1200, } assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 drawings = _read_manifest(root)["drawings"] @@ -375,6 +376,7 @@ def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: "name": "배수유역도", "pieces": [], "layout": "watershed", + "scale": 6000, } # 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장 stations = [