From 05026b04dbece87768c112ba5c9c5de281165f54 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:20:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(E04):=20=ED=83=90=EC=83=89=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EB=85=B8=EC=84=A0=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=95=9E=20=C2=B7=20=EC=A2=85=EC=A0=90=20=EB=92=A4?= =?UTF-8?q?=20=EB=BA=8C=20=C2=B7=20=EC=A7=80=EB=8F=84=20=EC=B5=9C=EC=86=8C?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=3D=20=EC=9E=84=EA=B3=84=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EC=B2=B4=20=ED=8F=89=EB=A9=B4=20=EC=A4=91=EC=8B=AC=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EA=B5=AC=EA=B0=84=20=ED=8C=8C?= =?UTF-8?q?=EB=9E=80=20=EC=84=A0=20(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Run.py | 6 ++++-- E04_Stab3D/E04_Stab3D_UI_Map.ts | 19 +++++++++++++------ E04_Stab3D/E04_Stab3D_UI_Style.css | 2 +- E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 16 ++++++++++++++++ resources/tester/test_533_3_e04_engine.py | 14 ++++++++++++++ resources/tester/test_533_3_e04_logic.py | 11 +++++++++++ 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 8ffc5fbb7..299ca5ca5 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -33,7 +33,7 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e04-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 @@ -129,7 +129,8 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: def _node_mask( root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float ) -> np.ndarray: - """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안.""" + """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안 + · 가장 가까운 점이 노선 양 끝점인 셀(노선 시점 앞 · 종점 뒤)은 뺌.""" points = sorted( source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] ) @@ -145,6 +146,7 @@ def _node_mask( dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) chain = line[at, 2] keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6) + keep &= (at > 0) & (at < len(line) - 1) return keep.reshape(xx.shape) diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts index 85b8d5ebe..407511ee3 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Map.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -7,7 +7,7 @@ import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; -import { cutCells } from "./logic/E04_Stab3D_Mesh"; +import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh"; import { FS_STEPS, VOLUME_STEPS, @@ -40,6 +40,8 @@ interface View { scale: number; // px / m } +const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음 + const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); const css = (name: string, fallback: string): string => @@ -61,6 +63,7 @@ export function createPlanMap(): PlanMap { let layer: MapLayer = "fs"; let image: HTMLCanvasElement | null = null; let outline: Set = new Set(); + let marker: { x: number; y: number } | null = null; let view: View = { cx: 0, cy: 0, scale: 1 }; let width = 0; let height = 0; @@ -118,7 +121,7 @@ export function createPlanMap(): PlanMap { if (line.length < 2) return; ctx.lineJoin = ctx.lineCap = "round"; ctx.strokeStyle = text; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.beginPath(); line.forEach((p, i) => i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), @@ -184,9 +187,9 @@ export function createPlanMap(): PlanMap { } function drawMarker(text: string): void { - if (!result) return; + if (!result || !marker) return; const { best } = result; - const [px, py] = toScreen(best.x, best.y); + const [px, py] = toScreen(marker.x, marker.y); ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.beginPath(); @@ -245,7 +248,7 @@ export function createPlanMap(): PlanMap { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); const text = css("--color-text", "#1f2937"); - const accent = css("--color-primary", "#6d28d9"); + const accent = RANGE_COLOR; if (result && image) { const g = result.grid; const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); @@ -335,6 +338,8 @@ export function createPlanMap(): PlanMap { return { x: rect.left + px, y: rect.top + py }; }, result: () => result, + marker: () => marker, + outline: () => [...outline], layer: () => layer, }; (window as unknown as { __e04Map?: typeof probe }).__e04Map = probe; @@ -354,7 +359,9 @@ export function createPlanMap(): PlanMap { }, setResult(next) { result = next; - outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []); + const cut = next ? cutCells(next.grid, next.z, next.best) : new Map(); + outline = new Set(cut.keys()); + marker = next ? footprintCenter(next.grid, cut, next.best) : null; buildImage(); fit(); draw(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 7b0c9b7ca..f215ec07a 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -86,7 +86,7 @@ .e04-map__empty { position: absolute; - inset: auto 0 45% 0; + inset: var(--spacing-16) 0 auto 0; margin: 0; text-align: center; color: var(--color-text-muted); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index b191f4e79..66361007a 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -90,6 +90,22 @@ export function cutCells( return out; } +/** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */ +export function footprintCenter( + grid: Grid, + cut: ReadonlyMap, + best: SlipBest, +): { x: number; y: number } { + if (!cut.size) return { x: best.x, y: best.y }; + let sx = 0; + let sy = 0; + for (const index of cut.keys()) { + sx += grid.x0 + (index % grid.nx) * grid.dx; + sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx; + } + return { x: sx / cut.size, y: sy / cut.size }; +} + /** 잘린 셀의 8 이웃 중 잘리지 않은 셀(값 있음) — 활동면 테두리 */ export function rimCells( grid: Grid, diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index 9e3497618..a3549dc85 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -178,3 +178,17 @@ def test_hooks_do_not_change_search(): with pytest.raises(InterruptedError): search(strata, SearchParams(**base, should_stop=lambda: True)) assert math.isfinite(plain.best["governing"]) + + +def test_node_mask_section_and_route_ends(root): + """노드 자리 = 구간 누가 안 · 탐색 폭 안 · 노선 시점 앞(x < 0) · 종점 뒤(x > 100)는 뺌.""" + x = np.arange(-30.0, 131.0, 1.0) + y = np.arange(-20.0, 21.0, 1.0) + grid = {"x": x, "y": y} + mask = run._node_mask(root, grid, 0.0, 100.0, 12.0) + xx, yy = np.meshgrid(x, y) + assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any() + assert not mask[np.abs(yy) > 12.0].any() + assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all() + part = run._node_mask(root, grid, 20.0, 60.0, 12.0) + assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all() diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index 380ba706a..b26ff6e6a 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({ sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)), ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)), cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()), + center: M.footprintCenter(c.grid, M.cutCells(c.grid, c.z, c.sphere), c.sphere), + centerEmpty: M.footprintCenter(c.grid, new Map(), c.sphere), floored: Object.fromEntries(M.cutCells(c.grid, c.z, { ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()), holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))), @@ -180,6 +182,15 @@ def test_slip_mesh_vertices(ts, name): assert all(z < Z[i] for i, z in cut.items()) +def test_footprint_center(ts): + """지도 최소 표시 = 잘린 셀 중심 평균 · 잘린 셀 없으면 구 중심.""" + cut = [i for i, _ in _py_cut(SPHERE)] + x = sum(GRID["x0"] + i % GRID["nx"] for i in cut) / len(cut) + y = sum(GRID["y0"] + i // GRID["nx"] for i in cut) / len(cut) + assert ts["center"] == pytest.approx({"x": x, "y": y}) + assert ts["centerEmpty"] == {"x": SPHERE["x"], "y": SPHERE["y"]} + + def test_floor_truncates_slip(ts): """못 뚫는 층 — 활동면이 층 윗면 아래로 안 내려감 · 층이 지표보다 높으면 잘린 셀 아님.""" center = 7 * 21 + 10 From c89898d00a05c9fc0bfdd8ae10b6e8850253ae8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:24:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(E04):=20=EC=A4=91=EC=8B=AC=EC=84=A0=20?= =?UTF-8?q?=3D=20=EC=B8=A1=EC=A0=90=20=EC=A4=91=EC=8B=AC(B06=20=EB=88=84?= =?UTF-8?q?=EA=B0=80)=20=E2=80=94=20route=5Fpoints=20=EB=8A=94=20=EC=A0=91?= =?UTF-8?q?=EC=84=A0=20=EA=BA=BE=EC=9D=80=EC=84=A0=EC=9D=B4=EB=9D=BC=20?= =?UTF-8?q?=EB=88=84=EA=B0=80=EA=B0=80=20=EB=8B=AC=EB=9D=BC=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EB=85=B8=EB=93=9C=20=C2=B7=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=EC=84=A0=EC=9D=B4=20=EC=96=B4=EA=B8=8B?= =?UTF-8?q?=EB=82=A8=20(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Router.py | 6 +++--- E04_Stab3D/E04_Stab3D_Run.py | 19 ++++++++++++------- resources/tester/test_533_3_e04_engine.py | 14 ++------------ resources/tester/test_533_3_e04_router.py | 15 --------------- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index a52acc53d..2416a95e8 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -184,7 +184,6 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: for item in saved: current = run.fingerprint(finger_source, ground, item.pop("settings")) item["stale"] = item.pop("fingerprint") != current - points = source.db_snapshot(root).get("route_points") or [] return { "sections": run.auto_sections(stations), "stations": [ @@ -196,9 +195,10 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: } for s in stations ], + # 중심선 = 측점 중심(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름) "line": [ - [float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])] - for p in sorted(points, key=lambda p: p["sequence_num"]) + [float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] + for s in sorted(stations, key=lambda s: float(s["chainage_m"])) ], "saved": saved, "defaults": SearchSettings().model_dump(), diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 299ca5ca5..599886ce9 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -33,7 +33,7 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e04-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 @@ -127,14 +127,19 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: def _node_mask( - root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float + stations: list[dict[str, Any]], + grid: dict[str, Any], + start_m: float, + end_m: float, + half_width_m: float, ) -> np.ndarray: """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안 - · 가장 가까운 점이 노선 양 끝점인 셀(노선 시점 앞 · 종점 뒤)은 뺌.""" - points = sorted( - source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + · 가장 가까운 점이 중심선 양 끝점인 셀(시점 앞 · 종점 뒤)은 뺌. + + 중심선 = 측점 중심을 이은 선(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름).""" + line = np.array( + [[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] for s in stations] ) - line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) # 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가 dense = [line[:1]] for a, b in zip(line[:-1], line[1:]): @@ -199,7 +204,7 @@ def run_engine( strata = grid_engine.strata_from_grid( grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None ) - mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half) + mask = _node_mask(stations, grid, settings["start_m"], settings["end_m"], half) ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"])) params = search_engine.SearchParams( node_mask=mask, diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index a3549dc85..011197fd3 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -83,16 +83,6 @@ def root(tmp_path, monkeypatch): z = _ground(yy) np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch))) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda r: { - "route_points": [ - {"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i} - for i, ch in enumerate(CHAINAGES) - ] - }, - ) return tmp_path @@ -185,10 +175,10 @@ def test_node_mask_section_and_route_ends(root): x = np.arange(-30.0, 131.0, 1.0) y = np.arange(-20.0, 21.0, 1.0) grid = {"x": x, "y": y} - mask = run._node_mask(root, grid, 0.0, 100.0, 12.0) + mask = run._node_mask(STATIONS, grid, 0.0, 100.0, 12.0) xx, yy = np.meshgrid(x, y) assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any() assert not mask[np.abs(yy) > 12.0].any() assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all() - part = run._node_mask(root, grid, 20.0, 60.0, 12.0) + part = run._node_mask(STATIONS, grid, 20.0, 60.0, 12.0) assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all() diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index aac24b02b..0e7793b5d 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -57,21 +57,6 @@ def project(tmp_path, monkeypatch): z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # 노선 옆 비탈 np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "stations", lambda root: STATIONS) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda root: { - "route_points": [ - { - "model_x": s["center_x"], - "model_y": 200.0, - "chainage_m": s["chainage_m"], - "sequence_num": i, - } - for i, s in enumerate(STATIONS) - ] - }, - ) async def fake_root(project_id): return tmp_path