fix(B07): 배수유역도 메모리 부족 — 배경선 솎기 · 배열 캐시 · 해칭 선분 재사용

- 등고선·세류선: 종이 0.1 mm(펜 굵기) 아래 굴곡 점 제거 — 294,339점 → 약 95,600점
- 배경선 캐시: 파이썬 튜플 → numpy 배열 + 선마다 범위 · 도곽 밖 선은 자르기 전에 거름 · 투영 한 줄씩 묶어 처리
- HatchEntity: 경계·무늬가 그대로면 줌·이동 때 패턴 선분 다시 계산 안 함
- 검증_sub4 유역도: 응답 75 MB → 24 MB · 서버 최고 1,304 MB → 595 MB · 끝 489 MB → 288 MB
- 브라우저: 전 = 힙 289 MB 넘다 페이지 죽음 · 후 = 최고 203 MB 열림 · 줌 20번 275~281 MB 고정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-06 17:55:32 +09:00
co-authored by Claude Opus 5.5
parent 0fb1af7e1d
commit 719455ae2b
4 changed files with 102 additions and 26 deletions
@@ -18,6 +18,8 @@ import math
from typing import Any
from uuid import uuid5
from shapely.geometry import LineString
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
DRAWING_FORMAT,
@@ -94,6 +96,9 @@ _COMPASS_SIZE = 26.0 # 방위표 긴 변(mm)
_COMPASS_GAP = 6.0 # 방위표와 첫 정보표 사이(mm)
# 노선은 지형·유역 위에 얹혀야 보인다 — 굵기도 배경선보다 굵게 한다.
_ROUTE_WIDTH = 3
# 배경선(등고선·세류선)은 종이에서 이보다 작게 꺾이는 점을 뺀다 — 펜 굵기보다 가늘어
# 안 보이는 점이다. 그대로 두면 수십만 선분이 서버 응답·브라우저 메모리를 다 먹는다.
_BACKGROUND_SIMPLIFY_MM = 0.1
def map_area_mm() -> tuple[float, float]:
@@ -109,6 +114,13 @@ def map_area_mm() -> tuple[float, float]:
)
def _thinned(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다)."""
if len(points) < 3:
return points
return list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords)
def _number(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
@@ -298,7 +310,7 @@ def build_watershed_drawing(
for index, line in enumerate(contours):
contour = polyline_entity(
drawing_id,
[paper(point) for point in line],
_thinned([paper(point) for point in line]),
CONTOUR_LAYER_ID,
CONTOUR_COLOR,
suffix=f":contour:{index}",
@@ -308,7 +320,7 @@ def build_watershed_drawing(
for index, line in enumerate(streams):
stream = polyline_entity(
drawing_id,
[paper(point) for point in line],
_thinned([paper(point) for point in line]),
STREAM_LAYER_ID,
STREAM_COLOR,
suffix=f":stream:{index}",
@@ -11,6 +11,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
@@ -186,32 +187,50 @@ def _too_far_from_route(
@lru_cache(maxsize=8)
def _metric_lines_cached(
path_str: str, mtime_ns: int, crs: str
) -> tuple[tuple[tuple[float, float], ...], ...]:
) -> tuple[tuple[np.ndarray, ...], np.ndarray]:
"""도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다.
같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면
한 장 여는 데 수 초가 걸린다(등고선 수만 점). 파일이 바뀌면 mtime 이 달라져
캐시가 저절로 갈린다(`_read_template` 와 같은 방식).
선은 (n, 2) 실수 배열로 쥔다 — 파이썬 튜플이면 등고선 한 벌이 수백 MB 를 붙든다.
둘째 값은 선마다 (min_x, min_y, max_x, max_y) — 도곽 밖 선을 자르기 전에 거른다.
"""
to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
lines: list[tuple[tuple[float, float], ...]] = []
lines: list[np.ndarray] = []
for feature in _geojson_features(Path(path_str)):
for part in _geometry_lines(feature.get("geometry")):
converted = tuple(
(float(x), float(y))
for x, y in (to_metric.transform(point[0], point[1]) for point in part)
)
if len(converted) >= 2:
lines.append(converted)
return tuple(lines)
if len(part) < 2:
continue
lonlat = np.array([(point[0], point[1]) for point in part], dtype=float)
x, y = to_metric.transform(lonlat[:, 0], lonlat[:, 1])
lines.append(np.column_stack((x, y)))
bounds = np.array(
[(*line.min(axis=0), *line.max(axis=0)) for line in lines], dtype=float
).reshape(-1, 4)
return tuple(lines), bounds
def _metric_lines(path: Path, crs: str) -> list[list[tuple[float, float]]]:
"""캐시된 배경 선을 쓰기 좋은 형태로 낸다. 파일이 없으면 빈 목록."""
def _metric_lines(
path: Path, crs: str, box: tuple[float, float, float, float] | None = None
) -> list[list[tuple[float, float]]]:
"""캐시된 배경 선 중 box 에 걸치는 것만 낸다. 파일이 없으면 빈 목록."""
if not path.is_file():
return []
cached = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs)
return [list(line) for line in cached]
lines, bounds = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs)
if box is None:
picked = range(len(lines))
else:
min_x, min_y, max_x, max_y = box
hit = (
(bounds[:, 2] >= min_x)
& (bounds[:, 0] <= max_x)
& (bounds[:, 3] >= min_y)
& (bounds[:, 1] <= max_y)
)
picked = np.flatnonzero(hit)
return [list(map(tuple, lines[index].tolist())) for index in picked]
def map_background(
@@ -248,7 +267,7 @@ def map_background(
background: dict[str, list[list[tuple[float, float]]]] = {}
for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)):
lines: list[list[tuple[float, float]]] = []
for line in _metric_lines(sheet_dir / filename, crs):
for line in _metric_lines(sheet_dir / filename, crs, box):
lines.extend(clip_line_to_box(line, box))
background[key] = lines
return background
@@ -360,7 +379,7 @@ def _clip_rings(
) -> list[list[tuple[float, float]]]:
"""행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀)."""
rings: list[list[tuple[float, float]]] = []
for ring in _metric_lines(path, crs):
for ring in _metric_lines(path, crs, box):
rings.extend(clip_line_to_box(ring, box))
return rings
@@ -6,7 +6,7 @@ import { Box, Point, Polygon, Segment } from '@flatten-js/core';
import type { Shape, SnapPoint } from '../App.types';
import { SnapPointType } from '../App.types';
import type { DrawController } from '../drawControllers/DrawController';
import { hatchSpans } from '../helpers/geometry/hatch-lines';
import { type Span, hatchSpans } from '../helpers/geometry/hatch-lines';
import { getExportColor } from '../helpers/get-export-color';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis';
import { scalePoint } from '../helpers/scale-point';
@@ -50,6 +50,8 @@ export class HatchEntity implements Entity {
private points: Point[];
public options: HatchOptions;
/** 패턴 선분 — 줌·이동마다 다시 그리므로 경계·무늬가 그대로면 지난 계산을 쓴다. */
private spanCache?: { key: string; points: Point[]; spans: Span[] };
constructor(layerId: string, points: Point[], options?: Partial<HatchOptions>) {
this.layerId = layerId;
@@ -77,14 +79,8 @@ export class HatchEntity implements Entity {
this.drawGradient(drawController);
} else {
drawController.setLineStyles(highlighted, selected, this.options.color, this.lineWidth);
const angles =
this.options.style === 'cross'
? [this.options.angle, this.options.angle + Math.PI / 2]
: [this.options.angle];
for (const angle of angles) {
for (const [start, end] of hatchSpans(this.points, angle, this.options.spacing)) {
drawController.drawLine(start, end);
}
for (const [start, end] of this.patternSpans()) {
drawController.drawLine(start, end);
}
}
@@ -101,6 +97,18 @@ export class HatchEntity implements Entity {
}
}
private patternSpans(): Span[] {
const { style, angle, spacing } = this.options;
const key = `${style}:${angle}:${spacing}`;
if (this.spanCache?.points === this.points && this.spanCache.key === key) {
return this.spanCache.spans;
}
const angles = style === 'cross' ? [angle, angle + Math.PI / 2] : [angle];
const spans = angles.flatMap((value) => hatchSpans(this.points, value, spacing));
this.spanCache = { key, points: this.points, spans };
return spans;
}
/** 그라데이션 — 촘촘한 스캔선의 색을 조금씩 바꿔 표현한다 */
private drawGradient(drawController: DrawController): void {
const box = this.getBoundingBox();
+37
View File
@@ -0,0 +1,37 @@
"""B07 유역도 메모리 — 배경선 배열 캐시 · 도곽 밖 선 거름 · 종이 0.1 mm 솎기 (59-4)."""
import json
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _thinned
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _metric_lines
def test_metric_lines_filters_by_box(tmp_path) -> None:
path = tmp_path / "lines.geojson"
inside = [[0.0, 0.0], [1.0, 1.0], [2.0, 0.5]]
outside = [[50.0, 50.0], [51.0, 51.0]]
path.write_text(
json.dumps(
{
"type": "FeatureCollection",
"features": [
{"geometry": {"type": "LineString", "coordinates": inside}},
{"geometry": {"type": "LineString", "coordinates": outside}},
],
}
),
encoding="utf-8",
)
every = _metric_lines(path, "EPSG:4326")
assert every == [[tuple(p) for p in inside], [tuple(p) for p in outside]]
assert _metric_lines(path, "EPSG:4326", (-1.0, -1.0, 5.0, 5.0)) == every[:1]
assert _metric_lines(tmp_path / "none.geojson", "EPSG:4326") == []
def test_thinned_drops_sub_pen_wiggles() -> None:
points = [(x * 0.5, 0.02 if x % 2 else 0.0) for x in range(21)]
thinned = _thinned(points)
assert thinned[0] == points[0] and thinned[-1] == points[-1]
assert len(thinned) == 2
bend = [(0.0, 0.0), (5.0, 1.0), (10.0, 0.0)]
assert _thinned(bend) == bend