From 164024ab435b5095b2b509fb772bd5a1d67ed5d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:19:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(532-5):=20E03=202D=20=ED=95=B4=EC=84=9D?= =?UTF-8?q?=EC=97=90=20=EB=B2=BD(=EC=98=B9=EB=B2=BD=20=C2=B7=20=EC=84=9D?= =?UTF-8?q?=EC=B6=95)=20=EC=97=B0=EA=B2=B0=20=C2=B7=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EC=B8=A1=EC=A0=90=20=EB=9D=A0(E02=20?= =?UTF-8?q?=C2=B7=20E03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze2d 가 측점마다 벽(walls_by_station · 복사 지문으로 기억) · 그려지는 설계선을 넘김 · 벽 있는 측점만 지문에 벽 · 벽 없는 측점 = 종전 지문 - 측점 보기 = 같은 벽 모형 · 수위선 = E02 고른 지하수 갈래(station_groundwater) - 화면 — 「반영 전」 안내 걷음 · 벽 회색 빗금 · 결과 「벽 있음」 · 목록 벽 표시 - result2d · stab/ground 에 missing_stations 칸 덧붙임 · 화면 위 띠(펼침 목록) - E03 지반 조건 = E02 화면과 같은 upgrade(옛 정본 지하수 칸 기본값) Co-Authored-By: Claude Opus 5.5 --- .../E01_StabRegister_UI_Missing.css | 21 +++++++ .../E01_StabRegister_UI_Missing.ts | 24 +++++++ E02_StabGround/E02_StabGround_Router.py | 10 ++- E02_StabGround/E02_StabGround_UI_Page.ts | 6 +- E03_StabSection/E03_StabSection_Router.py | 62 ++++++++++++++----- E03_StabSection/E03_StabSection_UI_List.ts | 7 ++- E03_StabSection/E03_StabSection_UI_Page.ts | 15 +++-- E03_StabSection/E03_StabSection_UI_Result.ts | 9 ++- E03_StabSection/E03_StabSection_UI_Section.ts | 35 ++++++++++- E03_StabSection/E03_StabSection_UI_Walls.css | 23 +++++++ E03_StabSection/E03_StabSection_View.py | 1 + E03_StabSection/E03_StabSection_Worker.py | 4 +- .../logic/E03_StabSection_Logic.ts | 2 + ui_template/ui_template_locale_e.ts | 6 ++ ui_template/ui_template_locale_e03.ts | 7 ++- 15 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.css create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Walls.css diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.css b/E01_StabRegister/E01_StabRegister_UI_Missing.css new file mode 100644 index 000000000..0e54a15cf --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.css @@ -0,0 +1,21 @@ +/* 빠진 횡단 띠 — E02 · E03 화면 위(E01_StabRegister_UI_Missing.ts) */ +.e-missing { + margin: 0 0 8px; + padding: 6px 10px; + border: 1px solid var(--color-warning); + border-left-width: 4px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: 13px; +} + +.e-missing > summary { + cursor: pointer; + font-weight: 600; +} + +.e-missing > p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.ts b/E01_StabRegister/E01_StabRegister_UI_Missing.ts new file mode 100644 index 000000000..252d0ccc9 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * E01_StabRegister_UI_Missing.ts + * 빠진 횡단 띠(요구 351 · PLAN 532-5) — E02 · E03 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」. + * 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 누르면 펼침. + * ========================================================================== */ + +import "./E01_StabRegister_UI_Missing.css"; +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; + +/** 빠진 측점이 없으면 빈 칸(띠 없음) */ +export function buildMissingBanner(missing: number[] | undefined): HTMLElement { + const list = missing ?? []; + if (!list.length) return el("div"); + const box = el("details", { + className: "e-missing", + attrs: { "data-role": "missing-stations", "data-count": String(list.length) }, + }); + box.append( + el("summary", { text: t("E_Missing_Head").replace("{n}", String(list.length)) }), + el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), + ); + return box; +} diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index 1b85d0c70..aee593430 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -1,6 +1,7 @@ """E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3). - `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지 + + 횡단 없는 측점(`missing_stations` · 화면 띠) - `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) @@ -27,7 +28,7 @@ from common_util.common_util_project_copy import MANIFEST_NAME from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M -from E01_StabRegister.E01_StabRegister_Source import db_snapshot, source_dir +from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir from E02_StabGround import E02_StabGround_Model as model router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) @@ -96,6 +97,12 @@ async def seed_initial(project_id: str) -> None: ensure_initial(root) +def _missing(project_root: Path) -> list[float]: + """횡단 없어 해석에서 빠진 측점 — 복사본이 없으면 빈 목록.""" + root = source_dir(project_root) + return missing_stations(root) if (root / MANIFEST_NAME).is_file() else [] + + def _read_working(project_root: Path) -> dict[str, Any] | None: path = model.ground_path(project_root) return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None @@ -119,6 +126,7 @@ async def get_ground( "saved": working is not None, "defaults": defaults, "editable": _editable(session, project), + "missing_stations": _missing(root), } diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index e0e985ef4..b45bb974f 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -20,6 +20,7 @@ import { import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildCards, type E02View } from "./E02_StabGround_UI_Tables"; import { checkGround, @@ -33,6 +34,7 @@ interface GroundResponse { saved?: boolean; defaults?: GroundDefaults; editable?: boolean; + missing_stations?: number[]; } const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; @@ -95,6 +97,7 @@ export function renderE02StabGround(root: HTMLElement): void { let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let missing: number[] = []; const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; @@ -127,7 +130,7 @@ export function renderE02StabGround(root: HTMLElement): void { if (!view) return; const cards = buildCards(view); refreshStations = cards.refreshStations; - main.replaceChildren(cards.root); + main.replaceChildren(buildMissingBanner(missing), cards.root); mark(); }; @@ -221,6 +224,7 @@ export function renderE02StabGround(root: HTMLElement): void { void (async () => { try { const body = await request(`/projects/${projectId}/stab/ground`); + missing = body.missing_stations ?? []; view = { ground: body.ground, defaults: body.defaults as GroundDefaults, diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 7e8b3c17b..82ef4bea5 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -2,7 +2,7 @@ - `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈 - `GET …/stab/progress` — 진행률 -- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면) +- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수) + 횡단 없는 측점 - `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자 - `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json` - `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용) @@ -10,8 +10,8 @@ 입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값) 계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` — -측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 그 측점 지층. 공유 회사(보기만)는 -`require_project_access` 가 POST · PUT 를 막음. +측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 지하수 갈래 · 그 측점 지층 · 벽. +공유 회사(보기만)는 `require_project_access` 가 POST · PUT 를 막음. """ from __future__ import annotations @@ -49,6 +49,7 @@ from E01_StabRegister import E01_StabRegister_Source as src from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Router as e02 from E03_StabSection import E03_StabSection_View as view +from E03_StabSection import E03_StabSection_Walls as walls_mod from E03_StabSection import E03_StabSection_Worker as worker from E03_StabSection.E03_StabSection_Engine_Model import ( ground_from_cross, @@ -94,8 +95,9 @@ def engine_hash() -> str: def _ground(root: Path) -> dict[str, Any]: - """E02 지반 조건 — 작업본 · 없으면 초기값.""" - return e02._read_working(root) or e02.ensure_initial(root) + """E02 지반 조건 — 작업본 · 없으면 초기값(옛 정본 = 빠진 지하수 칸 기본값 · E02 화면과 같음).""" + ground = e02._read_working(root) or e02.ensure_initial(root) + return ground_model.upgrade(ground, ground_model.load_defaults()) def _layers(resolved: dict[str, Any] | None) -> list | None: @@ -109,6 +111,12 @@ def _layers(resolved: dict[str, Any] | None) -> list | None: return [[resolved["upper"], None]] +@lru_cache(maxsize=4) +def _walls(source: str, _copy_fp: str) -> dict[str, dict[str, Any]]: + """측점별 벽(532-4 · Node 한 번 약 2 s) — 복사본은 동결이라 복사 지문으로 기억.""" + return walls_mod.walls_by_station(source) + + def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: """측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400.""" try: @@ -126,10 +134,16 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: "road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0), } resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + try: + found = _walls(str(source), copy_fp) + except RuntimeError as error: + raise HTTPException(status_code=500, detail=str(error)) from error items = [] for station in stations: key = chainage_key(station["chainage_m"]) layers = _layers(resolved.get(key)) + entry = found.get(key) + parts = [engine_hash(), copy_fp, shared, key, layers] + ([entry] if entry else []) items.append( { "key": key, @@ -138,7 +152,8 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: "ground_type": station.get("ground_type"), "center": [station.get("center_x"), station.get("center_y")], "layers": layers, - "fp": derived_key(engine_hash(), copy_fp, shared, key, layers), + "walls": entry, + "fp": derived_key(*parts), # 벽 없는 측점 = 종전 지문 꼴 } ) factors = ground.get("safety_factors") or {} @@ -153,7 +168,12 @@ def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross) except (KeyError, TypeError, ValueError): ground = ground_from_cross(cross) - return {"cross": cross, "design": cross["design"], "ground": [list(p) for p in ground]} + return { + "cross": cross, + "design": walls_mod.design_with_walls(cross["design"], item.get("walls")), + "walls": walls_mod.walls_of(item.get("walls")), + "ground": [list(p) for p in ground], + } def _sampler(source: Path) -> Any: @@ -166,7 +186,12 @@ def _sampler(source: Path) -> Any: def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]: keys = ("key", "chainage_m", "label", "ground_type", "center", "fp") - return {**{k: item[k] for k in keys}, "section_mode": design.get("section_mode")} + walls = len(walls_mod.walls_of(item.get("walls")) or []) + return { + **{k: item[k] for k in keys}, + "section_mode": design.get("section_mode"), + "walls": walls, + } def _run_worker(job: dict[str, Any], emit: Any) -> None: @@ -323,8 +348,9 @@ async def result2d( summary, fresh = _latest_summary(root, plan["fp"]) editable = e02._editable(session, project) running = bool((_JOBS.get(str(project_id)) or {}).get("running")) + missing = src.missing_stations(plan["source"]) # 횡단 없어 해석에서 빠진 측점(화면 띠) if not summary: - rows = [view.station_row(item, None, plan["factors"]) for item in plan["items"]] + rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]] view.mark_representative(rows, _overrides(root)) return { "status": "none", @@ -334,6 +360,7 @@ async def result2d( "conditions": plan["shared"]["conditions"], "factors": plan["factors"], "stations": rows, + "missing_stations": missing, } rows = summary["stations"] view.mark_representative(rows, _overrides(root)) @@ -344,6 +371,7 @@ async def result2d( "running": running, **{k: v for k, v in summary.items() if k != "stations"}, "stations": rows, + "missing_stations": missing, } @@ -360,18 +388,22 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]: raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.") value = saved["value"] cross = src.cross_section(plan["source"], row["chainage_m"]) + entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None) + design = walls_mod.design_with_walls(cross["design"], entry) kw = { "materials": plan["shared"]["materials"], "layers": value["inputs"]["layers"], "ground": [tuple(p) for p in value["inputs"]["ground"]], "road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"], + "walls": walls_mod.walls_of(entry), } - dry = section_model_from_b06(cross, cross["design"], **kw) - wet = section_model_from_b06(cross, cross["design"], wet=True, **kw) - water = { - cond: (wet["groundwater"].get("line") if cond == "wet" else None) - for cond in summary["conditions"] - } + dry = section_model_from_b06(cross, design, **kw) + water = {} + for cond in summary["conditions"]: + # 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음) + model = section_model_from_b06(cross, design, wet=cond == "wet", **kw) + gw = ground_model.station_groundwater(ground, cond, model) + water[cond] = gw.get("line") if gw.get("mode") == "line" else None out = view.section_view(row, value["out"], dry, water) out["fresh"] = fresh return out diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 4a88a343e..6a810878a 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -82,7 +82,12 @@ export function buildList( onToggle(row.key); }); tr.append( - el("td", { text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key }), + el("td", { + text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + children: row.walls + ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] + : [], + }), el("td", { text: row.sides.length ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 53bc15939..bd1c3c1b1 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -20,12 +20,14 @@ import { } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; import { buildPlan } from "./E03_StabSection_UI_Plan"; import { buildResult } from "./E03_StabSection_UI_Result"; import { buildLegends, createSectionCanvas, + WALL_ID, type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; @@ -46,6 +48,8 @@ interface Result2d { computed_at?: string; summary?: Record; stations: StationRow[]; + /** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */ + missing_stations?: number[]; } interface Progress { @@ -156,6 +160,7 @@ export function renderE03StabSection(root: HTMLElement): void { panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- + const missingHost = el("div"); const planHost = el("div"); const toolbar = el("div", { className: "e03-card__head" }); const sectionCard = el("div", { className: "e03-card" }); @@ -168,7 +173,7 @@ export function renderE03StabSection(root: HTMLElement): void { const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } }); const body = el("div", { className: "e03-body" }); body.append(sectionCard, resultHost); - main.append(planHost, body); + main.append(missingHost, planHost, body); const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; @@ -290,10 +295,9 @@ export function renderE03StabSection(root: HTMLElement): void { toolbar.append(title, sideField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); - legendHost.append( - buildLegends(draw), - el("p", { className: "e03-muted", text: t("E03_NoWalls") }), - ); + legendHost.append(buildLegends(draw)); + if (view.regions.some((r) => r.material_id === WALL_ID)) + legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; @@ -335,6 +339,7 @@ export function renderE03StabSection(root: HTMLElement): void { result = await api(`${base}/result2d`); if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 sections.clear(); + missingHost.replaceChildren(buildMissingBanner(result.missing_stations)); const conds = result.conditions; if (!conds.includes(condition)) condition = conds[0] ?? "dry"; condField.setOptions( diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index 18f41337a..d8b7fb5d0 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -17,7 +17,7 @@ import { type Point, type SectionView, } from "./logic/E03_StabSection_Logic"; -import { materialClass, svgEl } from "./E03_StabSection_UI_Section"; +import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); @@ -32,7 +32,7 @@ export function verdictChip(ok: boolean | null): HTMLElement { }); } -function kpi(analysis: Analysis): HTMLElement { +function kpi(analysis: Analysis, walls: number): HTMLElement { const c = analysis.critical; const dl = el("dl", { className: "e03-kpi" }); const row = (label: string, value: HTMLElement | string, big = false): void => { @@ -54,6 +54,8 @@ function kpi(analysis: Analysis): HTMLElement { : "-", ); row(t("E03_Height"), `${fmt(analysis.height_m)} m`); + if (walls) + row(t("E03_Walls"), el("span", { text: String(walls), attrs: { "data-role": "walls" } })); if (c?.center) { row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`); row(t("E03_Radius"), `${fmt(c.radius)} m`); @@ -284,7 +286,8 @@ export function buildResult( } const head = (key: LocaleKey): HTMLElement => el("h3", { className: "e03-card__title", text: t(key) }); - root.append(head("E03_Result"), kpi(analysis), methodTable(analysis)); + const walls = view.regions.filter((r) => r.material_id === WALL_ID).length; + root.append(head("E03_Result"), kpi(analysis, walls), methodTable(analysis)); root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick)); root.append(head("E03_Fbd"), freeBody(view, analysis, picked)); root.append(head("E03_Graph"), stressGraph(analysis, picked)); diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 273d9f866..7a50b1f15 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -1,11 +1,12 @@ /* ============================================================================= * E03_StabSection_UI_Section.ts - * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 + * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 벽(회색 빗금 · 532-5) · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 * 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름). * 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic. * 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율). * ========================================================================== */ +import "./E03_StabSection_UI_Walls.css"; import { t } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; @@ -27,6 +28,8 @@ import { const SVG_NS = "http://www.w3.org/2000/svg"; const PAD = 28; const MAX_SCALE = 30; +/** 벽(옹벽 · 석축) 물성 id — 서버 `WALL_MATERIAL` */ +export const WALL_ID = "wall"; export function svgEl( tag: K, @@ -87,7 +90,9 @@ export function buildLegends(draw: SectionDraw): HTMLElement { for (const m of draw.view.materials.filter((x) => used.has(x.id))) { const item = el("span", { className: "e03-legend__item" }); item.append( - el("span", { className: `e03-legend__swatch ${materialClass(draw.view, m.id)}` }), + el("span", { + className: `e03-legend__swatch ${m.id === WALL_ID ? "e03-wall-swatch" : materialClass(draw.view, m.id)}`, + }), m.name, ); box.append(item); @@ -121,10 +126,34 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec const ns = { "vector-effect": "non-scaling-stroke" }; const unit = 1 / fit.k; // 1 px 의 결과 좌표 길이 + // 벽 빗금 — 결과 좌표 패턴(화면 6 px 간격) + const hatch = svgEl("pattern", { + id: "e03-wall-hatch", + patternUnits: "userSpaceOnUse", + width: 6 * unit, + height: 6 * unit, + patternTransform: "rotate(45)", + }); + hatch.append( + svgEl("rect", { width: 6 * unit, height: 6 * unit, class: "e03-wall-hatch__bg" }), + svgEl("line", { + x1: 0, + y1: 0, + x2: 0, + y2: 6 * unit, + "stroke-width": 1.5 * unit, + class: "e03-wall-hatch__line", + }), + ); + const defs = svgEl("defs"); + defs.append(hatch); + world.append(defs); for (const region of view.regions) { + const wall = region.material_id === WALL_ID; const ring = svgEl("polygon", { points: pointsText(region.polygon), - class: `e03-region ${materialClass(view, region.material_id)}`, + class: wall ? "e03-wall" : `e03-region ${materialClass(view, region.material_id)}`, + ...(wall ? { fill: "url(#e03-wall-hatch)", "data-role": "wall" } : {}), ...ns, }); world.append(ring); diff --git a/E03_StabSection/E03_StabSection_UI_Walls.css b/E03_StabSection/E03_StabSection_UI_Walls.css new file mode 100644 index 000000000..13c7771c5 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Walls.css @@ -0,0 +1,23 @@ +/* E03 횡단 벽(옹벽 · 석축) 회색 빗금 · 목록 표시(532-5) */ +.e03-wall { + stroke: var(--e03-surface-line); + stroke-width: 1; +} + +.e03-wall-hatch__bg { + fill: #9a9a9a; + fill-opacity: 0.35; +} + +.e03-wall-hatch__line { + stroke: #6b6b6b; +} + +.e03-wall-swatch { + background: repeating-linear-gradient(45deg, #6b6b6b 0 2px, #c8c8c8 2px 5px); +} + +.e03-wall-tag { + margin-left: 4px; + color: var(--color-text-secondary); +} diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py index a52d20470..fcefcfbcb 100644 --- a/E03_StabSection/E03_StabSection_View.py +++ b/E03_StabSection/E03_StabSection_View.py @@ -76,6 +76,7 @@ def station_row( "ground_type": meta.get("ground_type"), "center": meta.get("center"), "fp": meta.get("fp"), + "walls": meta.get("walls") or 0, "sides": list(sides.values()), "cases": cases, "min": minimum, diff --git a/E03_StabSection/E03_StabSection_Worker.py b/E03_StabSection/E03_StabSection_Worker.py index 9dcc4b2d4..2e661d97b 100644 --- a/E03_StabSection/E03_StabSection_Worker.py +++ b/E03_StabSection/E03_StabSection_Worker.py @@ -3,7 +3,7 @@ `python -m E03_StabSection.E03_StabSection_Worker ` — 서버(`Router.run_all`)가 띄움. 측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률. 서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠. -job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, ground}]}. +job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, walls, ground}]}. """ from __future__ import annotations @@ -24,6 +24,8 @@ logger = logging.getLogger(__name__) def _analyze(item: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]: kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]} + if item.get("walls"): + kw["walls"] = item["walls"] return analyze_station(item["cross"], item["design"], **kw) diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index 134e8ade0..f1bcda7ce 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -31,6 +31,8 @@ export interface StationRow { min: Partial>; verdict: Partial>; error: string | null; + /** 그 측점 벽(옹벽 · 석축) 수 — 옛 저장본은 없음 */ + walls?: number; representative?: { auto: string[]; user: boolean | null; on: boolean }; } diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index eccacb836..d262b9d94 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -140,4 +140,10 @@ export const ui_locales_e = { E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"], E02_Err_type: ["꼴이 틀립니다", "Invalid shape"], E02_Err_few_points: ["점이 둘 이상 있어야 합니다", "At least two points are required"], + + /* --- E02 · E03 빠진 횡단 띠 --- */ + E_Missing_Head: [ + "횡단 없는 측점 {n} 개 — 해석에서 빠짐", + "{n} stations without a cross-section — left out of the analysis", + ], } as const satisfies Record; diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 6e88d4d9b..0093ac164 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -73,9 +73,10 @@ export const ui_locales_e03 = { E03_Legend_Map: ["등안전율(Fs)", "Safety factor"], E03_Legend_Layers: ["지층", "Layers"], E03_Offset: ["거리", "Offset"], - E03_NoWalls: [ - "옹벽 · 석축은 아직 해석에 반영 전입니다(벽 없는 단면으로 셈).", - "Retaining walls and masonry are not yet reflected (analyzed without walls).", + E03_Walls: ["벽 있음", "Walls"], + E03_Walls_Hint: [ + "옹벽 · 석축(회색 빗금)은 강한 물체로 넣어 활동면이 지나지 않습니다.", + "Retaining walls (gray hatch) are modeled as rigid bodies — slip surfaces do not cross them.", ], /* --- 우측 결과 --- */