merge(D03 겹): dev 합침 — 581-1 겹 일곱 + 582 두 장 캔버스 + 579 · 580 그리기 나눔 양쪽 살림

- UI_Layers.ts: 582 바탕 캔버스(`base`) + 581-1 새 겹 일곱 · 겹 열둘 모두 `base: true` · 글 폭 기억 · drawRaster 둘 다
- UI_Style.css: 582 `.d03-plan__base` 블록 + 581-1 `--d03-layers-w` 글
- test_553_4: 580 DrawPaint 단언 + 581-1 createMapTools · LayerDefs 단언
- test_582: 바탕 겹 수 5 → 겹 모두(12) — 581-1 합침으로 낡은 수 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:55:18 +09:00
co-authored by Claude Opus 5.5
27 changed files with 2091 additions and 660 deletions
+189
View File
@@ -0,0 +1,189 @@
/* 582 등고선 줌 단계 색인 헬퍼 — `B04_PreProcess_UI_MapRender_Prepare.ts` 의 `indexLayerLod` 를 붙인
* 레이어와 안 붙인 레이어를 같은 `drawPreparedLayer` 로 가짜 캔버스에 그려 그리기 명령(차례 · 좌표)을
* 줌 · 자리마다 맞대고 JSON 으로 낸다. 글 폭 기억(`drawPreparedLabels` widths)도 같은 방식.
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) });
const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts");
const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts");
const W = 1400;
const H = 900;
const META = { width_meters: 10000, height_meters: 8000 };
const NORMALIZER = { lonMin: 127, latMin: 36, lonRange: 0.1, latRange: 0.08, aspect: 1.25 };
/** 그리기 명령을 글로 적는 가짜 캔버스. */
function recorder() {
const log = [];
const rec =
(name) =>
(...args) =>
log.push(`${name}(${args.map((v) => (typeof v === "number" ? v.toFixed(6) : v)).join(",")})`);
let measured = 0;
return {
log,
measured: () => measured,
beginPath: rec("beginPath"),
moveTo: rec("moveTo"),
lineTo: rec("lineTo"),
closePath: rec("closePath"),
stroke: rec("stroke"),
arc: rec("arc"),
fill: rec("fill"),
save: rec("save"),
restore: rec("restore"),
translate: rec("translate"),
rotate: rec("rotate"),
strokeText: rec("strokeText"),
fillText: rec("fillText"),
measureText: (text) => {
measured += 1;
return { width: text.length * 6.1 };
},
};
}
/** 정해진 씨앗의 난수(결과가 매번 같게). */
function random(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
/** 등고선 비슷한 가닥 — 구불구불한 열린 선 · 닫힌 고리 · 짧은 조각. */
function collection() {
const rnd = random(582);
const features = [];
const lonLat = (x, y) => [127 + x * 0.1, 36 + (1 - y) * 0.08];
for (let f = 0; f < 60; f += 1) {
const kind = f % 3; // 0 열린 긴 선 · 1 닫힌 고리 · 2 짧은 조각
const n = kind === 2 ? 3 + Math.floor(rnd() * 40) : 200 + Math.floor(rnd() * 1800);
const cx = rnd();
const cy = rnd();
const pts = [];
if (kind === 1) {
const r = 0.02 + rnd() * 0.2;
for (let i = 0; i < n; i += 1) {
const a = (i / n) * Math.PI * 2;
const wobble = 1 + 0.15 * Math.sin(a * 7 + f) + 0.01 * (rnd() - 0.5);
pts.push(lonLat(cx + r * wobble * Math.cos(a), cy + r * wobble * Math.sin(a)));
}
pts.push(pts[0]);
} else {
let x = cx;
let y = cy;
let heading = rnd() * Math.PI * 2;
for (let i = 0; i < n; i += 1) {
heading += (rnd() - 0.5) * 0.6;
const step = 0.0004 + rnd() * 0.0008;
x += Math.cos(heading) * step;
y += Math.sin(heading) * step;
pts.push(lonLat(x, y));
}
}
const geometry =
kind === 1
? { type: "Polygon", coordinates: [pts] }
: f % 5 === 0
? { type: "MultiLineString", coordinates: [pts, pts.slice(0, Math.max(2, n >> 1))] }
: { type: "LineString", coordinates: pts };
features.push({ geometry, properties: { 등고수치: 400 + 5 * f } });
}
return { features };
}
const plain = prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]);
const indexed = prep.indexLayerLod(prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]));
let lodParts = 0;
let shortParts = 0;
for (const feature of indexed.features)
for (const part of feature.parts) {
if (part.lod) lodParts += 1;
else shortParts += 1;
}
const SCALES = [1, 1.7, 3, 7, 13, 50, 200, 1000, 2000];
const OFFSETS = [
[0, 0],
[300, -200],
[-900, 500],
];
const mapRect = render.computeMapRect(
{ width_meters: META.width_meters, height_meters: META.height_meters },
W,
H,
);
const cases = [];
for (const scale of SCALES)
for (const [ox, oy] of OFFSETS) {
const view = { width: W, height: H, scale, offsetX: ox, offsetY: oy, mapRect };
const a = recorder();
const b = recorder();
for (const everyM of [undefined, 25]) {
render.drawPreparedLayer(a, plain, view, "dot", everyM);
render.drawPreparedLayer(b, indexed, view, "dot", everyM);
}
const widths = new Map();
const la = recorder();
const lb = recorder();
render.drawPreparedLabels(la, plain, view, "#fff", 5, 0, "#000");
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths);
const measuredFirst = lb.measured();
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths); // 두 번째 = 기억만 씀
const measuredSecond = lb.measured() - measuredFirst;
const half = lb.log.length / 2;
cases.push({
scale,
offset: [ox, oy],
commands: a.log.length,
same: a.log.join("\n") === b.log.join("\n"),
vertices: a.log.filter((line) => line.startsWith("lineTo")).length,
labelsSame: la.log.join("\n") === lb.log.slice(0, half).join("\n"),
labelsRepeatSame: lb.log.slice(0, half).join("\n") === lb.log.slice(half).join("\n"),
labelMeasuredPlain: la.measured(),
labelMeasuredCached: measuredSecond,
});
}
// 색인이 훑는 정점 수 — 다 줌아웃(배율 1)의 허용 오차에서 전부 대비 몇 %
const tolerance = 0.75 / (mapRect.width * 1);
let all = 0;
let scanned = 0;
for (const feature of indexed.features)
for (const part of feature.parts) {
all += part.coords.length / 2;
const list = part.lod ? prep.pickLodList(part.lod, tolerance) : null;
scanned += list ? list.length : part.coords.length / 2;
}
// 고른 단 = 「무게 ≥ 오차」 정점을 모두 담음(여러 오차)
let supersetOk = true;
for (const tol of [1e-2, 1e-3, 3e-4, 1e-4, 2e-5, 5e-6, 1e-6, 1e-7])
for (const feature of indexed.features)
for (const part of feature.parts) {
if (!part.lod) continue;
const list = prep.pickLodList(part.lod, tol);
if (!list) continue;
const inList = new Set(list);
for (let v = 0; v < part.weights.length; v += 1)
if (part.weights[v] >= tol && !inList.has(v)) supersetOk = false;
for (let k = 1; k < list.length; k += 1) if (list[k] <= list[k - 1]) supersetOk = false;
}
process.stdout.write(
JSON.stringify({
lodParts,
shortParts,
closedParts: indexed.features.filter((f) => f.parts.some((p) => p.closed)).length,
cases,
scannedRatio: scanned / all,
supersetOk,
noLodFinest: prep.pickLodList(indexed.features[0].parts[0].lod, 1e-9) === null,
}),
);
+38 -28
View File
@@ -1,5 +1,5 @@
"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기
· 연도 잇기(D03 [저장]이 씀).
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1) — 경위도 ↔ TM 변환 · 시점 · 종점 목록 정본 API ·
옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
"""
@@ -7,7 +7,7 @@
import json
import pytest
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_company
@@ -80,32 +80,53 @@ def api(monkeypatch):
return client
def test_put_two_points(api) -> None:
got = api.put(URL, json={"start": A, "end": C})
def test_put_lists(api) -> None:
got = api.put(URL, json={"starts": [A, B], "ends": [C]})
assert got.status_code == 200
data = got.json()
assert data["epsg"] == 5186
assert (data["start"]["x"], data["start"]["y"]) == pytest.approx(
assert data["epsg"] == 5186 and len(data["starts"]) == 2 and len(data["ends"]) == 1
assert (data["starts"][0]["x"], data["starts"][0]["y"]) == pytest.approx(
lonlat_to_tm(127.10, 37.50, 5186)
)
assert abs(data["end"]["lon"] - C["lon"]) < 1e-8 and abs(data["end"]["lat"] - C["lat"]) < 1e-8
end = data["ends"][0]
assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8
stored = json.loads(api.state["project"]["plan_base_json"])
assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"}
assert set(stored) == {"epsg", "starts", "ends"} and set(stored["starts"][1]) == {"x", "y"}
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
def test_put_one_point_and_empty(api) -> None:
data = api.put(URL, json={"start": A}).json()
assert data["start"] and data["end"] is None and data["epsg"] == 5186
def test_put_partial_and_empty(api) -> None:
data = api.put(URL, json={"starts": [A]}).json()
assert len(data["starts"]) == 1 and data["ends"] == [] and data["epsg"] == 5186
data = api.put(URL, json={}).json()
assert data == {"status": "success", "epsg": None, "start": None, "end": None}
assert data == {"status": "success", "epsg": None, "starts": [], "ends": []}
assert api.put(URL, json={"starts": [A] * 21}).status_code == 422 # 상한 20
def test_put_keeps_stored_epsg(api) -> None:
api.put(URL, json={"starts": [A], "ends": [C]})
far = {"lon": 128.9, "lat": 37.5} # 평균이 128° 를 넘어도 저장된 5186 그대로
data = api.put(URL, json={"starts": [A], "ends": [far, far]}).json()
assert data["epsg"] == 5186
def test_get_wraps_old_single_points(api) -> None:
a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C))
old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}}
api.state["project"]["plan_base_json"] = json.dumps(old)
data = api.get(URL).json()
assert [p["x"] for p in data["starts"]] == pytest.approx([a[0]])
assert [p["x"] for p in data["ends"]] == pytest.approx([c[0]])
old["end"] = None
api.state["project"]["plan_base_json"] = json.dumps(old)
assert api.get(URL).json()["ends"] == []
def test_put_forest_only(api) -> None:
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
assert api.put(URL, json={"start": A, "end": B}).status_code == 403
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 403
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
assert api.put(URL, json={"start": A, "end": B}).status_code == 200
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 200
def test_get_folds_old_year_rows(api) -> None:
@@ -119,19 +140,8 @@ def test_get_folds_old_year_rows(api) -> None:
}
data = api.get(URL).json()
assert data["epsg"] == epsg
assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0]))
assert (data["starts"][0]["x"], data["ends"][0]["x"]) == pytest.approx((a[0], c[0]))
def test_get_empty_new_project(api) -> None:
assert api.get(URL).json() == {"status": "success", "epsg": None, "start": None, "end": None}
def test_chain_years_for_d03() -> None:
items = [
router_mod.YearPoints(year=2026, start=A, end=B),
router_mod.YearPoints(year=2027, start=A, end=C),
]
rows = router_mod.chain_years(2026, 2027, items)
assert rows[1]["start"].lon == B["lon"] # 시점 = 직전 종점
with pytest.raises(HTTPException):
router_mod.chain_years(2026, 2027, items[:1])
assert api.get(URL).json() == {"status": "success", "epsg": None, "starts": [], "ends": []}
+2 -2
View File
@@ -184,7 +184,7 @@ class _Db:
def client(monkeypatch):
db = _Db()
monkeypatch.setattr(net, "get_db_pool", lambda: db)
base = {"epsg": 5186, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 90.0, "y": 180.0}}
base = {"epsg": 5186, "starts": [{"x": 0.0, "y": 0.0}], "ends": [{"x": 90.0, "y": 180.0}]}
async def load_base(project_id):
return base
@@ -214,7 +214,7 @@ def test_get_saved_legacy_or_empty(client) -> None:
client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}]
got = client.get(URL).json()
assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3
assert got["base"]["end"] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026
assert got["base"]["ends"] == [{"x": 90.0, "y": 180.0}] and got["year_start"] == 2026
def test_put_stores_network_pieces_years(client) -> None:
+3 -3
View File
@@ -31,8 +31,8 @@ class _Pool:
def client(tmp_path, monkeypatch):
plan = {
"epsg": 5186,
"start": {"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]},
"end": {"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]},
"starts": [{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}],
"ends": [{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}],
}
async def load_base(project_id):
@@ -61,7 +61,7 @@ def test_extent_uses_whole_ends(client) -> None:
extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json()
assert extra["box_m"][2] == 2000 + extra["margin_m"]
assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400
client.plan["end"] = None # 종점 빔 — 1단계로
client.plan["ends"] = [] # 종점 빔 — 1단계로
assert client.get(f"{URL}/extent").status_code == 400
+125 -6
View File
@@ -1,7 +1,9 @@
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 ·
화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -19,6 +21,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
@@ -27,7 +30,8 @@ _RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
const Y = 2026;
let n = plan.emptyNetwork([0, 0]);
const empty = plan.emptyNetwork();
let n = plan.newLine(empty, [0, 0]);
n = plan.appendVertex(n, 0, [100, 0]);
n = plan.appendVertex(n, 0, [100, 100]);
n = plan.normalizePieces(n, Y);
@@ -52,10 +56,57 @@ const read = plan.fromServer(
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
Y,
);
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
j = plan.newLine(j, [200, 0]);
j = plan.appendVertex(j, 1, [150, 50]);
j = plan.appendVertex(j, 1, [100, 0]);
j = plan.normalizePieces(j, 2027);
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
const sameYear = plan.normalizePieces(
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
Y,
);
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
let b = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
Y,
);
b = plan.newLine(b, [100, 80]);
b = plan.appendVertex(b, 1, [100, 0]);
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
let m = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
let r = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
const removeBlocked = plan.removeVertex(m, 0, 1);
const lineBlocked = plan.removeLine(m, 0);
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
const branchFlip = plan.reverseLine(m, 1);
const lineYear = plan.setLineYear(m, "main", 2029);
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
];
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
reached: plan.reachedEnd(n, [100, 100]),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
}));
"""
@@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None:
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
assert got["reached"] is True
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
def test_branch_order_and_guard(tmp_path: Path) -> None:
@@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
("main", 50.0, 200.0, 2027),
("branch-1", 0.0, 60.0, 2026),
]
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
joined = got["joined"]
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
assert len(joined["lines"]) == 1
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
total = 100 + 2 * (50**2 * 2) ** 0.5
spans = _spans(joined)
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
assert abs(spans[1][1] - total) < 1e-3
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
branch = got["asBranch"]["lines"][1]
assert branch["branch"] == {"line": "main", "vertex": 1}
assert branch["vertices"] == [[100, 0], [100, 80]]
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
body = {**joined, "marks": [], "epsg": 5186}
body["marks"] = [
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
]
assert len(server.derive(body, 2026)["pieces"]) == 2
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
moved = got["movedV"]
assert moved["lines"][0]["vertices"][1] == [100, -100]
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
first = (100**2 + 100**2) ** 0.5
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
removed = got["removedV"]
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
assert abs(_spans(removed)[1][0] - share) < 1e-3
assert "갈래가 붙은 점" in got["removeBlocked"]
assert "갈래가 붙은 선" in got["lineBlocked"]
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
assert "갈래는 방향을" in got["branchFlip"]
def test_reverse_year_mark_move(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rev = got["reversed"]
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
server.derive(body, 2026)
def test_line_names_mirror(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
cases = [
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
+2 -1
View File
@@ -47,7 +47,8 @@ def test_d03_parts():
assert '"rotate-ccw", "rotate-cw"' in tools
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
assert "map.stations" in draw and "map.slopes" in draw
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
assert "map.stations" in paint and "map.slopes" in paint
# 569-2 · 581-1 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts")
assert "createMapTools({" in layers and "createIconButton({" in layers
+88
View File
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""582 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로(D03 먼저).
- 색인 붙인 레이어 = 안 붙인 레이어와 그리기 명령(차례 · 좌표)이 똑같음 — 여러 줌 · 자리 ·
닫힌 고리 · 짧은 조각(색인 없음) · 간격 거름(25 m)
- 고른 단은 「무게 ≥ 허용 오차」 정점을 모두 담고 원래 차례 · 아주 확대하면 색인 없이 전부 훑음
- 다 줌아웃에서 훑는 정점이 줄어듦
- 글 폭 기억 = 같은 글 · 같은 자리 · 두 번째부터 재지 않음
- 색인 · 바탕 캔버스는 D03 만 켬(B04 · B05 는 안 부름)
Node 헬퍼(`helper_582_contour_lod.cjs`)가 진짜 TS 를 가짜 캔버스로 돌린다.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_582_contour_lod.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_조각_갈래가_다_있음(r):
assert r["lodParts"] > 20
assert r["shortParts"] > 10
assert r["closedParts"] > 10
def test_색인으로_그린_명령이_지금과_같음(r):
assert len(r["cases"]) == 27
for case in r["cases"]:
assert case["same"], case
# 줌 · 자리마다 그린 정점 수가 실제로 달라짐(거름이 일함)
assert len({c["vertices"] for c in r["cases"]}) > 15
assert all(c["vertices"] > 0 for c in r["cases"] if c["scale"] <= 13)
def test_고른_단은_남는_정점을_모두_담음(r):
assert r["supersetOk"]
assert r["noLodFinest"]
def test_다_줌아웃_훑기가_줄어듦(r):
assert r["scannedRatio"] < 0.5
def test_글_폭_기억은_같은_글(r):
for case in r["cases"]:
assert case["labelsSame"], case
assert case["labelsRepeatSame"], case
assert case["labelMeasuredCached"] == 0
assert any(c["labelMeasuredPlain"] > 0 for c in r["cases"])
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_D03_만_켬():
layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert "indexLayerLod(prepare(collection" in layers
# 581-1 합침 — 겹 모두 바탕 캔버스
assert layers.count("base: true") == layers.count("draw: (context, map)") == 12
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
assert "requestAnimationFrame(flush)" in plan_map
assert "drawTop();" in plan_map
users = [
p.relative_to(ROOT).as_posix()
for folder in ("B04_PreProcess", "B05_Profile", "B06_Section", "A00_Common")
for p in (ROOT / folder).rglob("*.ts")
if "indexLayerLod(" in p.read_text(encoding="utf-8")
]
assert users == ["B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts"]