Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_4
This commit is contained in:
@@ -24,6 +24,7 @@ from config.config_system import (
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SHEET_SURFACE_DEFAULT_METHOD,
|
||||
SURFACE_AUTO_METHODS,
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD,
|
||||
SURFACE_CONTOUR_INTERVAL_M,
|
||||
)
|
||||
|
||||
@@ -67,7 +68,8 @@ async def trigger_wf1_analysis_and_email(
|
||||
# 자동 전처리는 기본 조합만 만든다 — 필터는 엔진이 입력 LAS를 보고 정하고
|
||||
# (분류 있으면 classification, 없으면 csf), 표현은 SURFACE_AUTO_METHODS.
|
||||
# 다른 조합은 관리자가 B04 드롭다운에서 요청할 때 만든다 (2026-09-01 사용자 확정).
|
||||
methods = list(SURFACE_AUTO_METHODS)
|
||||
# 확정 표현(implicit)도 함께 지음 — 엔진이 기본 필터에만 지음(요구 558).
|
||||
methods = list(dict.fromkeys([*SURFACE_AUTO_METHODS, SURFACE_CONFIRM_DEFAULT_METHOD]))
|
||||
params = {
|
||||
"input_file_id": str(input_file_id),
|
||||
"source_filters": None,
|
||||
@@ -176,10 +178,10 @@ async def trigger_wf1_analysis_and_email(
|
||||
else:
|
||||
# 자동 확정은 엔진이 정한 기본 필터를 그대로 따른다 — 이제 그 필터
|
||||
# 하나만 만들어 두므로 config 기본값과 어긋나면 확정할 모델이 없다.
|
||||
# 표현 · 평활은 config 확정 기본값(implicit · 평활 끔 — 요구 558).
|
||||
selection = surface_confirmation_defaults()
|
||||
if resolved_filters:
|
||||
selection["source_filter"] = resolved_filters[0]
|
||||
selection["method"] = methods[0]
|
||||
try:
|
||||
model_id = await find_surface_model_for_selection(
|
||||
connection, project_id, selection
|
||||
|
||||
@@ -279,7 +279,7 @@ def run_surface_analysis(
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
# 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다.
|
||||
if not source_filters:
|
||||
source_filters = resolve_auto_source_filters(data)
|
||||
source_filters = resolve_auto_source_filters(data, processed_dir)
|
||||
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
|
||||
@@ -5,11 +5,18 @@
|
||||
쓸 수 있는지는 has_classified_ground()로 먼저 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import (
|
||||
SURFACE_CLASSIFIED_COVERAGE_CELL_M,
|
||||
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
|
||||
SURFACE_CLASSIFIED_GROUND_MIN_RATIO,
|
||||
)
|
||||
|
||||
# ASPRS 표준 지면 클래스 코드
|
||||
GROUND_CLASS_CODE = 2
|
||||
@@ -38,3 +45,51 @@ def has_classified_ground(
|
||||
return False
|
||||
ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE))
|
||||
return ground / total >= minimum_ratio
|
||||
|
||||
|
||||
def classified_coverage(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
cell_meters: float = SURFACE_CLASSIFIED_COVERAGE_CELL_M,
|
||||
) -> float:
|
||||
"""점 있는 칸 중 class 2 가 있는 칸의 비율 — 일부만 분류된 LAS 를 가려냄."""
|
||||
if "classification" not in structured_data:
|
||||
return 0.0
|
||||
classification = np.asarray(structured_data["classification"])
|
||||
xyz = structured_data["xyz"]
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
cell = max(float(cell_meters), 0.1)
|
||||
cols = int((bounds[0, 1] - bounds[0, 0]) // cell) + 1
|
||||
rows = int((bounds[1, 1] - bounds[1, 0]) // cell) + 1
|
||||
occupied = np.zeros((rows, cols), dtype=bool)
|
||||
ground = np.zeros((rows, cols), dtype=bool)
|
||||
for start in range(0, len(classification), 5_000_000):
|
||||
points = np.asarray(xyz[start : start + 5_000_000, :2], dtype=np.float64)
|
||||
gx = np.clip(((points[:, 0] - bounds[0, 0]) // cell).astype(np.int32), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - bounds[1, 0]) // cell).astype(np.int32), 0, rows - 1)
|
||||
occupied[gy, gx] = True
|
||||
is_ground = classification[start : start + len(points)] == GROUND_CLASS_CODE
|
||||
ground[gy[is_ground], gx[is_ground]] = True
|
||||
total = int(np.count_nonzero(occupied))
|
||||
return float(np.count_nonzero(ground)) / total if total else 0.0
|
||||
|
||||
|
||||
def has_classified_coverage(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
processed_dir: Path | None = None,
|
||||
minimum_ratio: float = SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
|
||||
) -> bool:
|
||||
"""분류 덮임 문턱 판정 — `processed_dir` 면 structured.npz 지문으로 저장본 재사용."""
|
||||
if processed_dir is None:
|
||||
return classified_coverage(structured_data) >= minimum_ratio
|
||||
stat = (processed_dir / "structured.npz").stat()
|
||||
stamp = [int(stat.st_size), int(stat.st_mtime_ns)]
|
||||
path = processed_dir / "classification_coverage.json"
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
coverage = float(stored["coverage"]) if stored.get("structured") == stamp else None
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
coverage = None
|
||||
if coverage is None:
|
||||
coverage = classified_coverage(structured_data)
|
||||
atomic_write_json(path, {"structured": stamp, "coverage": coverage})
|
||||
return coverage >= minimum_ratio
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
source_filters를 따른다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
filter_classification,
|
||||
has_classified_coverage,
|
||||
has_classified_ground,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
|
||||
@@ -45,14 +47,18 @@ def usable_conditional_filters(
|
||||
|
||||
def resolve_auto_source_filters(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
processed_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다.
|
||||
|
||||
업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장
|
||||
안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수
|
||||
있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다.
|
||||
「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558).
|
||||
"""
|
||||
usable = usable_conditional_filters(structured_data)
|
||||
if usable and not has_classified_coverage(structured_data, processed_dir):
|
||||
usable = []
|
||||
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
|
||||
|
||||
|
||||
@@ -170,11 +170,19 @@ def build_nurbs(
|
||||
)
|
||||
|
||||
|
||||
def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int:
|
||||
"""implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부."""
|
||||
minimum = int(config["implicit_min_points"])
|
||||
if ground_count <= minimum:
|
||||
return int(ground_count)
|
||||
ratio = round(ground_count * float(config["implicit_point_ratio"]))
|
||||
return int(min(max(ratio, minimum), int(config["implicit_max_points"])))
|
||||
|
||||
|
||||
def build_implicit(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
maximum = max(100, int(context.config["implicit_max_points_per_tile"]))
|
||||
points = context.sample(maximum)
|
||||
points = context.grid_sample(implicit_point_count(context.source_count, context.config))
|
||||
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
|
||||
points = points[unique_indices]
|
||||
if len(points) < 4:
|
||||
|
||||
@@ -29,6 +29,8 @@ ProgressCallback = Callable[[int], None]
|
||||
|
||||
# 대용량 포인트 배치 처리 크기
|
||||
_BATCH_SIZE = 500_000
|
||||
# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점
|
||||
IMPLICIT_SAMPLE_SEED = 558
|
||||
|
||||
# NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²).
|
||||
# 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은
|
||||
@@ -262,6 +264,30 @@ class TerrainContext:
|
||||
self._samples[maximum] = points
|
||||
return points
|
||||
|
||||
def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray:
|
||||
"""implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움.
|
||||
|
||||
파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정.
|
||||
"""
|
||||
indices = self.indices()
|
||||
if len(indices) <= count:
|
||||
return np.asarray(self.xyz[indices], dtype=np.float32)
|
||||
_, _, resolution, footprint = self.footprint()
|
||||
cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01)
|
||||
xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64)
|
||||
rng = np.random.default_rng(seed)
|
||||
order = rng.permutation(len(indices))
|
||||
gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64)
|
||||
gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64)
|
||||
_, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True)
|
||||
chosen = order[first]
|
||||
if len(chosen) > count:
|
||||
chosen = rng.choice(chosen, count, replace=False)
|
||||
elif len(chosen) < count:
|
||||
rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True)
|
||||
chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)])
|
||||
return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32)
|
||||
|
||||
def footprint(self) -> tuple[float, float, float, np.ndarray]:
|
||||
if self._footprint is not None:
|
||||
return self._footprint
|
||||
|
||||
@@ -151,7 +151,13 @@ _REPRESENTATIONS = {
|
||||
def _cache_is_valid(
|
||||
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
|
||||
) -> bool:
|
||||
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
|
||||
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.
|
||||
|
||||
manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은
|
||||
옛 결과(평활 없는 implicit · nurbs · meshfree)를 다시 짓는다(요구 558).
|
||||
"""
|
||||
if entry.get("status") != "completed":
|
||||
return False
|
||||
ext = "ply" if method == "meshfree" else "glb"
|
||||
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
|
||||
output_dir / f"{stem}.npz"
|
||||
@@ -190,6 +196,8 @@ def _build_all_terrain_models(
|
||||
methods_list.remove("dtm")
|
||||
methods_list.insert(0, "dtm")
|
||||
methods = tuple(methods_list)
|
||||
# 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558).
|
||||
later_methods = tuple(method for method in methods if method != "implicit")
|
||||
signature = config_signature(config)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
xyz = structured_data["xyz"]
|
||||
@@ -211,7 +219,7 @@ def _build_all_terrain_models(
|
||||
}
|
||||
started_at = time.monotonic()
|
||||
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
|
||||
total_units = max(1, len(filters) * len(methods))
|
||||
total_units = max(1, len(methods) + (len(filters) - 1) * len(later_methods))
|
||||
done_units = 0
|
||||
failures = 0
|
||||
|
||||
@@ -229,27 +237,11 @@ def _build_all_terrain_models(
|
||||
)
|
||||
filter_entry["source_point_count"] = context.source_count
|
||||
|
||||
for method in methods:
|
||||
for method in later_methods if filter_index else methods:
|
||||
stem = f"{method}_{filter_key}"
|
||||
entry = filter_entry["methods"].get(method, {})
|
||||
|
||||
if not force and _cache_is_valid(output_dir, stem, method, entry, config):
|
||||
if entry.get("status") != "completed":
|
||||
entry.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"representation": _REPRESENTATIONS[method],
|
||||
"model_file": f"{stem}.npz",
|
||||
"preview_file": f"{stem}_preview."
|
||||
+ ("ply" if method == "meshfree" else "glb"),
|
||||
"preview_media_type": "application/octet-stream"
|
||||
if method == "meshfree"
|
||||
else "model/gltf-binary",
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
filter_entry["methods"][method] = entry
|
||||
_write_json_file(manifest_path, manifest)
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
needs_contours = not contour_path.exists()
|
||||
|
||||
@@ -54,7 +54,7 @@ import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_swi
|
||||
const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const;
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
const DEFAULT_FILTER = "csf";
|
||||
const DEFAULT_METHOD = "dtm";
|
||||
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
|
||||
const ROUTE_STAGE = ROUTES.B05_PROFILE;
|
||||
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
|
||||
const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"];
|
||||
@@ -494,7 +494,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
]);
|
||||
models = modelResponse.models;
|
||||
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
|
||||
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
|
||||
// 확정 이력이 없을 때만 개발 기본값(csf·implicit)으로 둔다.
|
||||
if (confirmed.model_id) {
|
||||
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
|
||||
if (confirmed.method) methodGroup.select.value = confirmed.method;
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type TreeRef,
|
||||
type TreeView,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { copyToLayer, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
|
||||
import {
|
||||
@@ -196,7 +196,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
|
||||
...(opt.copyTo ?? []).map((to) => ({
|
||||
label: to.label.replace("{n}", n),
|
||||
run: () => void copyTo(list, to),
|
||||
run: () => copyTo(list, to),
|
||||
})),
|
||||
];
|
||||
};
|
||||
@@ -243,6 +243,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
...(isTop
|
||||
? []
|
||||
: [
|
||||
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }, // 720-2 = 「+」 길
|
||||
{ label: L("M02_TreeRename"), run: () => void rename(target) },
|
||||
{ label: L("M02_TreeMove"), run: () => void move(target) },
|
||||
{ label: L("M02_Delete"), run: () => void remove(target) },
|
||||
@@ -554,25 +555,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
|
||||
}
|
||||
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */
|
||||
async function copyTo(list: Target[], to: CopyTarget): Promise<void> {
|
||||
if (!api.paste || !list.length) return;
|
||||
/** 「〇〇로 복사…」(39-6) — 몸은 `TreePanel_CopyTo` */
|
||||
function copyTo(list: Target[], to: CopyTarget): void {
|
||||
const paste_ = api.paste;
|
||||
if (!paste_ || !list.length) return;
|
||||
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
|
||||
try {
|
||||
const dest = await askCopyFolder(api.fetch, to, list.length);
|
||||
if (!dest) return;
|
||||
await api.paste(to.layer, to.owner, clip, dest);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
|
||||
showToast(
|
||||
L("M02_TreeCopiedTo")
|
||||
.replace("{n}", String(list.length))
|
||||
.replace("{layer}", where)
|
||||
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);
|
||||
}
|
||||
|
||||
async function paste(dest: string[]): Promise<void> {
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* M02_MasterTemplete_TreePanel_CopyTo.ts
|
||||
* 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null.
|
||||
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
|
||||
* `copyToLayer` = 고름 → 붙여넣기 → 알림(구조물 조합 나무 `TreePanel` 에서 뗌 · 720-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { Layer, TreeClip, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
@@ -26,3 +28,38 @@ export async function askCopyFolder(
|
||||
const title = to.label.replace("{n}", String(n));
|
||||
return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder"));
|
||||
}
|
||||
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 부른 나무는 그대로 */
|
||||
export async function copyToLayer(
|
||||
api: {
|
||||
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
|
||||
paste: (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
clip: TreeClip,
|
||||
dest: string[],
|
||||
) => Promise<TreeView>;
|
||||
},
|
||||
clip: TreeClip,
|
||||
to: CopyTarget,
|
||||
): Promise<void> {
|
||||
const n = String(clip.refs.length);
|
||||
try {
|
||||
const dest = await askCopyFolder(api.fetch, to, clip.refs.length);
|
||||
if (!dest) return;
|
||||
await api.paste(to.layer, to.owner, clip, dest);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
|
||||
showToast(
|
||||
L("M02_TreeCopiedTo")
|
||||
.replace("{n}", n)
|
||||
.replace("{layer}", where)
|
||||
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
L("M02_ActionFailed").replace("{value}", error instanceof Error ? error.message : ""),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +595,10 @@ export function createMain(
|
||||
sheet.recalc();
|
||||
panel.refresh(sheet.getDoc()?.열 ?? []);
|
||||
}
|
||||
// 720-3 — 서고 문서(단가산출 · 일위대가) 이름을 고쳐 저장하면 머리 제목도 새 이름(「키 이름」)
|
||||
const named = (doc as { 이름?: unknown } | null)?.이름;
|
||||
if ((at.kind === "sangeun" || isPrecombo(at.kind)) && typeof named === "string" && sel === at)
|
||||
title.textContent = at.label = named ? `${at.name} ${named}` : at.name;
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
onSaved?.(); // 좌측 목록의 판을 새 판으로
|
||||
|
||||
@@ -239,11 +239,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
void side.refreshPrecombo();
|
||||
};
|
||||
const preBar = (): HTMLElement =>
|
||||
// 층 단추 옆 「+」(720-2) = 서고 칸이 만든 맨 위 메뉴
|
||||
const preBar = (add: HTMLElement): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: pre.root, owner: pre.owner },
|
||||
company,
|
||||
add,
|
||||
// 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표
|
||||
go: (next) =>
|
||||
void main
|
||||
@@ -254,11 +256,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
});
|
||||
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
|
||||
let sk = preScope("personal");
|
||||
const skBar = (): HTMLElement =>
|
||||
const skBar = (add: HTMLElement): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: sk.root, owner: sk.owner },
|
||||
company,
|
||||
add,
|
||||
go: (next) =>
|
||||
void main
|
||||
.confirmLeave()
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
|
||||
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
|
||||
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
|
||||
* 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`.
|
||||
* 문서 줄 [이름 바꾸기](720-3) — 읽고 문서 `이름` 만 갈아 저장(서버 새 길 없음) · 열린 문서면 새 이름으로 다시 엶.
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
pasteLibraryTree,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
type LibraryKind,
|
||||
type PrecomboTree,
|
||||
@@ -58,8 +62,8 @@ export interface LibraryScope {
|
||||
export interface LibraryOptions {
|
||||
kind: LibraryKind;
|
||||
scope: () => LibraryScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 단추 옆 「+」(720-2) */
|
||||
bar: (add: HTMLElement) => HTMLElement;
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
@@ -130,6 +134,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
className: "ui-tree m02-tree__body",
|
||||
attrs: { "data-library-tree": kind, tabindex: "0" },
|
||||
});
|
||||
// 층 단추 옆 「+」(720-2 · 구조물 조합 꼴) — 맨 위 메뉴 · 읽기만 층 = 숨김(그릴 때)
|
||||
const add = createButton({
|
||||
label: "+",
|
||||
variant: "ghost",
|
||||
onClick: (event) => openTreeMenu(event, menuAt([])),
|
||||
});
|
||||
add.classList.add("m02-tree__add");
|
||||
add.title = L("M02_TreeAddTop");
|
||||
add.setAttribute("aria-label", L("M02_TreeAddTop"));
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
@@ -209,6 +222,34 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
|
||||
};
|
||||
|
||||
/** 문서 이름 바꾸기(720-3) — 열린 문서면 저장 안 한 고침 물음 → 읽고 `이름` 만 갈아 저장 → 나무 · 상세 새 이름 */
|
||||
const renameDoc = async (row: LibraryRow): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
const was = row.제목 || row.이름;
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{ key: "name", label: L("M02_TreeItemName"), value: was },
|
||||
]);
|
||||
const name = got?.name.trim();
|
||||
if (!name || name === was) return;
|
||||
const cur = opt.getOpen();
|
||||
const shown =
|
||||
cur?.kind === kind &&
|
||||
cur.name === row.이름 &&
|
||||
cur.layer === at.layer &&
|
||||
(cur.owner ?? null) === at.owner;
|
||||
if (shown && !(await opt.confirmLeave())) return;
|
||||
try {
|
||||
const doc = await readTemplate(at.layer, kind, row.이름, null, at.owner);
|
||||
const body = { ...(doc.문서 as Record<string, unknown>), 이름: name };
|
||||
await saveTemplate(at.layer, kind, row.이름, null, doc.판 ?? "", body, at.owner);
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
await refresh(true);
|
||||
if (shown) await open({ ...row, 제목: name });
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
/* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */
|
||||
const pickedTargets = (): Target[] =>
|
||||
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
|
||||
@@ -255,6 +296,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
item(opt.text.newHere, () => make(path)),
|
||||
...(path.length
|
||||
? [
|
||||
item(L("M02_TreeAddTopFolder"), () => void addFolder([])),
|
||||
item(L("M02_TreeRename"), () => void rename(path)),
|
||||
item(L("M02_Delete"), () => void remove([target])),
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
@@ -288,12 +330,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||
const target: Target = { kind: "item", item };
|
||||
targets.set(id, target);
|
||||
const off = (): string | undefined => (opt.scope().canWrite ? undefined : tc("ReadOnly"));
|
||||
const single = (): TreeMenuItem[] => [
|
||||
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]), disabledReason: off() },
|
||||
{ label: L("M02_TreeRename"), run: () => void renameDoc(row), disabledReason: off() },
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
{
|
||||
label: L("M02_Delete"),
|
||||
run: () => void remove([target]),
|
||||
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
|
||||
disabledReason: off(),
|
||||
},
|
||||
...(opt.more?.(row.이름, label) ?? []),
|
||||
];
|
||||
@@ -424,7 +469,8 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
const refresh = async (keep = false): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
add.hidden = !at.canWrite;
|
||||
barHost.replaceChildren(opt.bar(add));
|
||||
if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
|
||||
const [got, tree] = await Promise.all([
|
||||
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryOptions,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
@@ -53,8 +54,8 @@ export const precomboList = (scope: PrecomboScope): Selection => ({
|
||||
|
||||
export interface PrecomboOptions {
|
||||
scope: () => PrecomboScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 「+」(720-2) */
|
||||
bar: LibraryOptions["bar"];
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
|
||||
@@ -63,6 +63,11 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float(
|
||||
os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002")
|
||||
)
|
||||
# 자동 기본 고름만 — 점 있는 칸(한 변 아래 m) 중 class 2 가 있는 칸이 이 비율 이상이어야 분류 있음.
|
||||
SURFACE_CLASSIFIED_COVERAGE_CELL_M = float(os.getenv("SURFACE_CLASSIFIED_COVERAGE_CELL_M", "10.0"))
|
||||
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO = float(
|
||||
os.getenv("SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO", "0.5")
|
||||
)
|
||||
# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다.
|
||||
SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01"))
|
||||
|
||||
@@ -95,7 +100,8 @@ SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_S
|
||||
|
||||
# footprint(외곽) 산출
|
||||
SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0"))
|
||||
SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0"))
|
||||
# 외곽 틈 닫기 3 m — DJI 지면점이 성긴 숲 띠까지 스캔 범위로 살림(요구 558).
|
||||
SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "3.0"))
|
||||
SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0"))
|
||||
SURFACE_KEEP_LARGEST_FOOTPRINT = (
|
||||
os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true"
|
||||
@@ -111,9 +117,10 @@ SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0
|
||||
SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int(
|
||||
os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16")
|
||||
)
|
||||
SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int(
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000")
|
||||
)
|
||||
# implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부(요구 558).
|
||||
SURFACE_IMPLICIT_POINT_RATIO = float(os.getenv("SURFACE_IMPLICIT_POINT_RATIO", "0.175"))
|
||||
SURFACE_IMPLICIT_MIN_POINTS = int(os.getenv("SURFACE_IMPLICIT_MIN_POINTS", "50000"))
|
||||
SURFACE_IMPLICIT_MAX_POINTS = int(os.getenv("SURFACE_IMPLICIT_MAX_POINTS", "2000000"))
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15"))
|
||||
@@ -166,9 +173,10 @@ SHEET_SURFACE_AUTO_METHODS = [
|
||||
|
||||
# 일반 사용자 WF1 자동 확정 기본값
|
||||
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm")
|
||||
# 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558).
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit")
|
||||
SURFACE_CONFIRM_DEFAULT_SMOOTH = (
|
||||
os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true"
|
||||
os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "False").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
@@ -190,7 +198,9 @@ def build_surface_model_config() -> dict:
|
||||
"nurbs_degree": SURFACE_NURBS_DEGREE,
|
||||
"nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M,
|
||||
"nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS,
|
||||
"implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE,
|
||||
"implicit_point_ratio": SURFACE_IMPLICIT_POINT_RATIO,
|
||||
"implicit_min_points": SURFACE_IMPLICIT_MIN_POINTS,
|
||||
"implicit_max_points": SURFACE_IMPLICIT_MAX_POINTS,
|
||||
"implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING,
|
||||
"meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS,
|
||||
"meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M,
|
||||
|
||||
@@ -140,3 +140,12 @@ M01/M02에 대한 화면 검증은 잠시 미루고 라이다 데이터와 관
|
||||
1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음.
|
||||
2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행
|
||||
3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유
|
||||
|
||||
## 요구 561 (11-01 · 사용자 · 밤 맡김 다시 · 4 번 더함)
|
||||
|
||||
오늘은 나는 쉬러갈 생각임. 너가 브레인으로서 업무 수행할 내용
|
||||
|
||||
1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음.
|
||||
2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행
|
||||
3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유
|
||||
4. 현재 존재하는 프로젝트들에 사용되던 라이다 데이터를 영구저장소에 복제하여 넣고 내가 프로젝트를 접근하면 바로 확인할 수 있게 반영
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""640 라이다 기본 고름 · implicit 점 수 · 격자 칸 뽑기 · 지문 · 틈 닫기 (요구 558).
|
||||
|
||||
서버 함수만 — 작은 합성 점군 · 임시 폴더.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
classified_coverage,
|
||||
has_classified_coverage,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import implicit_point_count
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models
|
||||
from config import config_system as cfg
|
||||
|
||||
|
||||
def _plane(size_m: float = 60.0, spacing_m: float = 0.5) -> np.ndarray:
|
||||
axis = np.arange(0.0, size_m, spacing_m)
|
||||
x, y = np.meshgrid(axis, axis)
|
||||
z = 100.0 + 0.2 * x + 0.1 * np.sin(y / 5.0)
|
||||
return np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
||||
|
||||
|
||||
def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict:
|
||||
data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T}
|
||||
if classification is not None:
|
||||
data["classification"] = classification
|
||||
return data
|
||||
|
||||
|
||||
def test_확정_기본값은_implicit_평활_끔() -> None:
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_METHOD == "implicit"
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_SMOOTH is False
|
||||
assert cfg.SURFACE_AUTO_METHODS == ("dtm",) # D03 자체 라이다는 dtm 만 그대로
|
||||
|
||||
|
||||
def test_일부만_분류된_LAS_는_자동_기본에서_csf() -> None:
|
||||
points = _plane(100.0)
|
||||
partial = np.ones(len(points), dtype=np.uint8)
|
||||
partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 %
|
||||
assert classified_coverage(_structured(points, partial)) < 0.5
|
||||
assert resolve_auto_source_filters(_structured(points, partial)) == ["csf"]
|
||||
|
||||
spread = np.ones(len(points), dtype=np.uint8)
|
||||
spread[::37] = 2 # 비율 2.7 % · 덮임 100 %
|
||||
assert classified_coverage(_structured(points, spread)) == 1.0
|
||||
assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "csf"]
|
||||
|
||||
|
||||
def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None:
|
||||
points = _plane(40.0)
|
||||
classes = np.full(len(points), 2, dtype=np.uint8)
|
||||
(tmp_path / "structured.npz").write_bytes(b"x")
|
||||
assert has_classified_coverage(_structured(points, classes), tmp_path)
|
||||
assert (tmp_path / "classification_coverage.json").is_file()
|
||||
|
||||
class _NoXyz(dict):
|
||||
def __getitem__(self, key): # type: ignore[no-untyped-def]
|
||||
assert key != "xyz", "저장본이 맞으면 점군을 풀지 않아야 함"
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
assert has_classified_coverage(_NoXyz(_structured(points, classes)), tmp_path)
|
||||
|
||||
|
||||
def _config(**override: object) -> dict:
|
||||
config = cfg.build_surface_model_config()
|
||||
config.update({"source_filters": ["a", "b"], "precompute": ["dtm", "implicit"]})
|
||||
config.update(override)
|
||||
return config
|
||||
|
||||
|
||||
def test_implicit_은_기본_필터에만(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool), "b": np.arange(len(points)) % 2 == 0}
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, _config())
|
||||
assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"}
|
||||
assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"}
|
||||
assert not (tmp_path / "implicit_b.npz").exists()
|
||||
|
||||
|
||||
def test_implicit_점_수_비율_하한_상한() -> None:
|
||||
config = cfg.build_surface_model_config()
|
||||
assert (config["implicit_point_ratio"], config["implicit_min_points"]) == (0.175, 50_000)
|
||||
assert config["implicit_max_points"] == 2_000_000
|
||||
assert implicit_point_count(1_140_716, config) == 199_625 # 용화 분류
|
||||
assert implicit_point_count(768_273, config) == 134_448 # 용화 CSF
|
||||
assert implicit_point_count(100_000, config) == 50_000 # 하한
|
||||
assert implicit_point_count(30_000, config) == 30_000 # 하한보다 적으면 전부
|
||||
assert implicit_point_count(50_000_000, config) == 2_000_000 # 상한
|
||||
|
||||
|
||||
def test_격자_칸_뽑기는_고르고_씨앗_고정() -> None:
|
||||
rng = np.random.default_rng(1)
|
||||
dense = rng.uniform(0, 20, (200_000, 2)) # 왼쪽 아래 트인 곳 — 점 몰림
|
||||
sparse = rng.uniform(0, 100, (30_000, 2)) # 전체 성긴 숲 밑
|
||||
xy = np.vstack([dense, sparse])
|
||||
points = np.column_stack([xy, np.zeros(len(xy))])
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=cfg.build_surface_model_config(),
|
||||
)
|
||||
picked = context.grid_sample(5_000)
|
||||
assert len(picked) == 5_000 and len(np.unique(picked[:, :2], axis=0)) == 5_000
|
||||
assert np.array_equal(picked, context.grid_sample(5_000))
|
||||
in_dense = np.count_nonzero((picked[:, 0] < 20) & (picked[:, 1] < 20)) / len(picked)
|
||||
file_order = context.sample(5_000)
|
||||
in_dense_file = np.count_nonzero((file_order[:, 0] < 20) & (file_order[:, 1] < 20))
|
||||
assert in_dense < 0.1 < 0.5 < in_dense_file / len(file_order) # 파일 차례는 몰림 · 격자는 고름
|
||||
assert len(context.grid_sample(300_000)) == len(points) # 모자라면 전부
|
||||
|
||||
|
||||
def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool)}
|
||||
config = _config(
|
||||
source_filters=["a"],
|
||||
precompute=["implicit"],
|
||||
implicit_min_points=500,
|
||||
implicit_max_points=1_000,
|
||||
)
|
||||
build_all_terrain_models(data, masks, tmp_path, config)
|
||||
with np.load(tmp_path / "implicit_a.npz") as old:
|
||||
assert len(old["center_z"]) == 1_000
|
||||
changed = dict(config, implicit_max_points=2_000)
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, changed)
|
||||
with np.load(tmp_path / "implicit_a.npz") as new:
|
||||
assert len(new["center_z"]) == 2_000
|
||||
assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000
|
||||
|
||||
|
||||
def test_외곽_틈_닫기_3m_는_성긴_띠를_살림() -> None:
|
||||
points = _plane(60.0)
|
||||
gap = (points[:, 0] > 28) & (points[:, 0] < 33) # 5 m 빈 띠 — 1 m 닫기면 두 덩이
|
||||
points = points[~gap]
|
||||
|
||||
def area(close_m: float) -> float:
|
||||
config = dict(cfg.build_surface_model_config(), footprint_gap_close_meters=close_m)
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=config,
|
||||
)
|
||||
return context.footprint_metadata()["footprint_area_m2"]
|
||||
|
||||
assert cfg.SURFACE_FOOTPRINT_GAP_CLOSE_M == 3.0
|
||||
assert area(3.0) > 1.8 * area(1.0) # 한 덩이(띠 포함) 대 큰 덩이 하나
|
||||
@@ -0,0 +1,76 @@
|
||||
"""M02 폴더 · 층 · 단가산출 이름(PLAN 720 · 내역 16) — 임시 storage + 화면 글 대조.
|
||||
|
||||
720-1 단가산출 · 일위대가 층 단추 = 저장 안 한 고침 물음 → 그 층 목록표 ·
|
||||
720-2 최상위 폴더 = 메뉴 줄 · 층 줄 「+」 같은 `addFolder([])` ·
|
||||
720-3 문서 [이름 바꾸기] = 읽고 `이름` 만 갈아 저장(층 목록 제목 새 이름 · 다른 칸 그대로) ·
|
||||
720-4 구조물 조합 층 = 제자리 바꿈(해시 이동 없음).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from resources.tester.test_m02_precombo_folders import client # noqa: F401 — 같은 임시 층
|
||||
|
||||
M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
|
||||
ITEM = "/api/m02/layers/personal/templates/{}/{}"
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rows(c: TestClient, kind: str) -> dict[str, dict]:
|
||||
rows = c.get("/api/m02/layers/personal/templates").json()["양식"]
|
||||
return {r["이름"]: r for r in rows if r["종류"] == kind}
|
||||
|
||||
|
||||
def _rename(c: TestClient, kind: str, key: str, name: str) -> None:
|
||||
"""화면 `renameDoc` 과 같은 길 — 읽고 `이름` 만 갈아 같은 판으로 저장"""
|
||||
got = c.get(ITEM.format(kind, key)).json()
|
||||
body = {"판": got.get("판") or "", "문서": {**got["문서"], "이름": name}}
|
||||
res = c.put(ITEM.format(kind, key), json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
def test_일위대가_이름_바꾸기는_이름만(client: TestClient): # noqa: F811
|
||||
before = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
_rename(client, "precombo", "SC-000002", "나 고침")
|
||||
after = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
assert after["이름"] == "나 고침"
|
||||
assert {k: v for k, v in after.items() if k != "이름"} == {
|
||||
k: v for k, v in before.items() if k != "이름"
|
||||
}
|
||||
rows = _rows(client, "precombo")
|
||||
assert rows["SC-000002"]["제목"] == "나 고침" and rows["SC-000001"]["제목"] == "가"
|
||||
|
||||
|
||||
def test_단가산출_이름_바꾸기_목록_제목(client: TestClient): # noqa: F811
|
||||
body = {"문서": {"이름": "갑", "단위": "㎡", "바탕": None, "더한줄": []}}
|
||||
res = client.post("/api/m02/layers/personal/library/sangeun", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["이름"]
|
||||
_rename(client, "sangeun", key, "을")
|
||||
assert _rows(client, "sangeun")[key]["제목"] == "을"
|
||||
assert client.get(ITEM.format("sangeun", key)).json()["문서"]["단위"] == "㎡"
|
||||
|
||||
|
||||
def test_화면_층_단추_최상위_폴더_이름_바꾸기():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
lib = _text("M02_MasterTemplete_UI_Side_Library.ts")
|
||||
# 720-1 — 다른 층 단추 = 물음 뒤 목록표
|
||||
assert "setPre(next), void main.open(precomboList(pre))" in page
|
||||
assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0]
|
||||
# 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([])
|
||||
assert page.count(" add,\n") == 2
|
||||
assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib
|
||||
assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib
|
||||
assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([])' in lib # 문서 줄
|
||||
tree = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }' in tree
|
||||
# 720-3 — 문서 줄 [이름 바꾸기] · 저장 뒤 머리 제목
|
||||
assert "run: () => void renameDoc(row)" in lib
|
||||
assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main
|
||||
@@ -296,9 +296,10 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
assert "(to === layer && toOwner === here)) return [];" in page
|
||||
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
|
||||
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, clip.refs.length);" in copy_to
|
||||
assert "void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);" in _text(
|
||||
M02 / "M02_MasterTemplete_TreePanel.ts"
|
||||
)
|
||||
) # 720-2 — 몸은 CopyTo 로 뗌(TreePanel 700 줄)
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
|
||||
@@ -72,12 +72,12 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
|
||||
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
|
||||
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
|
||||
bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
# 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표
|
||||
go = bar.split("go: (next) =>")[1].split("reselect:")[0]
|
||||
assert "main.confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0]
|
||||
assert "main.confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
|
||||
assert page.count('layerBar("') == 1
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
@@ -107,6 +107,7 @@ export const ui_locales_m2 = {
|
||||
M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"],
|
||||
M02_TreeEmpty: ["항목이 없음", "No items"],
|
||||
M02_TreeAddFolder: ["폴더 추가", "Add folder"],
|
||||
M02_TreeAddTopFolder: ["최상위 폴더 만들기", "New top-level folder"],
|
||||
M02_TreeAddItem: ["항목 추가", "Add item"],
|
||||
M02_TreeRename: ["이름 바꾸기", "Rename"],
|
||||
M02_TreeMove: ["옮기기", "Move"],
|
||||
|
||||
Reference in New Issue
Block a user