fix(E04): 탐색 노드에서 노선 시점 앞 · 종점 뒤 뺌 · 지도 최소 표시 = 임계 활동체 평면 중심 · 고른 구간 파란 선 (533-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 21:20:30 +09:00
co-authored by Claude Opus 5.5
parent 15ce7cdd0b
commit 05026b04db
6 changed files with 59 additions and 9 deletions
+4 -2
View File
@@ -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)
+13 -6
View File
@@ -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<number> = 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<number, number>();
outline = new Set(cut.keys());
marker = next ? footprintCenter(next.grid, cut, next.best) : null;
buildImage();
fit();
draw();
+1 -1
View File
@@ -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);
+16
View File
@@ -90,6 +90,22 @@ export function cutCells(
return out;
}
/** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */
export function footprintCenter(
grid: Grid,
cut: ReadonlyMap<number, number>,
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,
+14
View File
@@ -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()
+11
View File
@@ -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