Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-25 22:45:38 +09:00
8 changed files with 72 additions and 42 deletions
+3 -3
View File
@@ -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(),
+14 -7
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-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
STAGE_DIR = "E04_Stab3D"
GROUND_FILE = Path("E02_StabGround") / "ground.json"
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
@@ -127,13 +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"]
"""탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안
· 가장 가까운 점이 중심선 양 끝점인 셀(시점 앞 · 종점 뒤)은 뺌.
중심선 = 측점 중심을 이은 선(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:]):
@@ -145,6 +151,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)
@@ -197,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,
+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 -10
View File
@@ -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
@@ -178,3 +168,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(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(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()
+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
-15
View File
@@ -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