diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 573c922a9..7e8b3c17b 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -170,24 +170,29 @@ def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]: def _run_worker(job: dict[str, Any], emit: Any) -> None: - """일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError.""" + """일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError. + + stderr 는 파일로(파이프로 두면 경고가 쌓여 일꾼이 멈춤).""" with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder: path = Path(folder) / "job.json" path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8") - process = subprocess.Popen( - [sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)], - cwd=PROJECT_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - ) - for line in process.stdout: - if line.startswith("{"): - emit(json.loads(line).get("done")) - error = process.stderr.read() - if process.wait() != 0: - raise RuntimeError(f"해석 일꾼 실패: {error.strip().splitlines()[-1:]}") + log = Path(folder) / "stderr.txt" + with log.open("w", encoding="utf-8") as errors: + process = subprocess.Popen( + [sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=errors, + text=True, + encoding="utf-8", + ) + for line in process.stdout: + if line.startswith("{"): + emit(json.loads(line).get("done")) + code = process.wait() + if code != 0: + tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:] + raise RuntimeError(f"해석 일꾼 실패: {tail}") def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 52c86ea45..4a88a343e 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -82,16 +82,22 @@ export function buildList( onToggle(row.key); }); tr.append( - el("td", { text: row.label?.replace(/^STA\./, "") ?? row.key }), + el("td", { text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key }), el("td", { - text: row.sides.length ? roleText(row, { cut: t("E03_Cut"), fill: t("E03_Fill") }) : "-", + text: row.sides.length + ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) + : "-", }), ...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })), el("td", { children: [ el("span", { className: `e03-verdict e03-verdict--${v}`, - text: row.error ? t("E03_Error") : t(`E03_Verdict_${v}` as LocaleKey), + text: row.error + ? t("E03_Error") + : v === "none" + ? "-" + : t(`E03_Verdict_${v}` as LocaleKey), }), ], }), diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 4287bfa09..53bc15939 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -331,7 +331,9 @@ export function renderE03StabSection(root: HTMLElement): void { } async function load(): Promise { + const before = result?.status; result = await api(`${base}/result2d`); + if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 sections.clear(); const conds = result.conditions; if (!conds.includes(condition)) condition = conds[0] ?? "dry"; diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index a9225f2aa..18f41337a 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -174,11 +174,18 @@ function freeBody(view: SectionView, analysis: Analysis, picked: number | null): "data-value": String(a.value), }), ); - const tip = a.name.startsWith("E") || a.name.startsWith("X") || a.name === "Q" ? p0 : p1; + // 글 = 화살 바깥 끝(들어오는 힘 = 꼬리 · 나가는 힘 = 머리)에서 화살 방향으로 조금 더 + const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) || 1; + const d: Point = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; + const ending = a.name !== "W"; + const out: Point = ending ? [-d[0], -d[1]] : d; + const at: Point = ending ? p0 : p1; + const anchor = out[0] > 0.3 ? "start" : out[0] < -0.3 ? "end" : "middle"; + const label = { x: at[0] + out[0] * 6, y: at[1] + out[1] * 6 + (out[1] > 0.3 ? 10 : 0) }; svg.append( svgEl( "text", - { x: tip[0] + 4, y: tip[1] - 4, class: "e03-label" }, + { ...label, "text-anchor": anchor, class: "e03-label" }, `${a.name} ${fmt(a.value, 1)}`, ), ); diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 7960d5042..273d9f866 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -13,6 +13,7 @@ import { bandIndex, bandLegend, fitView, + focusHome, pointsText, sliceShape, toModel, @@ -25,6 +26,7 @@ import { const SVG_NS = "http://www.w3.org/2000/svg"; const PAD = 28; +const MAX_SCALE = 30; export function svgEl( tag: K, @@ -307,7 +309,7 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec }); svg.addEventListener("mouseleave", () => (tip.style.display = "none")); - const key = `${view.key}|${width}x${height}`; + const key = `${view.key}|${analysis?.condition}|${analysis?.side}|${width}x${height}`; if (key !== zoomKey) zoom = undefined; zoomKey = key; attachSvgZoomPan({ @@ -317,7 +319,8 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec heightPx: height, pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, - maxScale: 20, + maxScale: MAX_SCALE, + home: focusHome(fit, view, analysis, width, height, MAX_SCALE), initial: zoom, onChange: (state) => (zoom = state), }); diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index 95b8a2665..63c515748 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -80,7 +80,7 @@ display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--spacing-8); - height: 100%; + height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-8) var(--spacing-12); box-sizing: border-box; min-height: 0; @@ -89,6 +89,7 @@ .e03-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 420px); + grid-template-rows: minmax(0, 1fr); gap: var(--spacing-8); min-height: 0; } @@ -214,9 +215,10 @@ } .e03-list tbody tr.is-active { - background: var(--color-lavender-wash); - outline: 1px solid var(--color-accent); - outline-offset: -1px; + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; + font-weight: var(--font-weight-medium); } .e03-verdict { @@ -508,7 +510,9 @@ } .e03-table tbody tr.is-picked { - background: var(--color-lavender-wash); + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; } .e03-slices { @@ -576,7 +580,7 @@ .e03-view3d { position: fixed; inset: 5vh 5vw; - z-index: 50; + z-index: 800; display: flex; flex-direction: column; border-radius: var(--radius-sm); diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts index 4f69b9229..4042d0d22 100644 --- a/E03_StabSection/E03_StabSection_UI_View3D.ts +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -149,7 +149,7 @@ export async function openView3d( const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); camera.position .copy(sphere.center) - .add(new THREE.Vector3(0, sphere.radius * 1.4, sphere.radius * 1.2)); + .add(new THREE.Vector3(0, sphere.radius * 1.9, sphere.radius * 1.6)); const controls = new OrbitControls(camera, renderer.domElement); controls.target.copy(sphere.center); controls.update(); diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index 9bf1903dd..134e8ade0 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -243,6 +243,43 @@ export function fitView(view: SectionView, width: number, height: number, pad: n return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 }; } +/** + * 처음 보기(줌 · 팬) — 그 비탈 탐색 범위 · 임계면 · 격자 중심을 판 가운데 크게(여백 25 %). + * 판 전체(scale 1) = 모형 전체 · 두 번 누름 = 이 보기 · 배율은 1~maxScale. + */ +export function focusHome( + fit: ViewFit, + view: SectionView, + analysis: Analysis | null, + width: number, + height: number, + maxScale: number, +): { scale: number; tx: number; ty: number } { + if (!analysis) return { scale: 1, tx: 0, ty: 0 }; + const pts: Point[] = []; + const [x0, x1] = analysis.limits; + view.surface.filter((p) => p[0] >= x0 && p[0] <= x1).forEach((p) => pts.push(p)); + analysis.critical?.points.forEach((p) => pts.push(p)); + if (analysis.grid) { + const g = analysis.grid; + pts.push([Math.min(...g.x), Math.min(...g.z)], [Math.max(...g.x), Math.max(...g.z)]); + } + if (!pts.length) return { scale: 1, tx: 0, ty: 0 }; + const scr = pts.map((p) => toScreen(fit, p)); + const [sx0, sx1] = [Math.min(...scr.map((p) => p[0])), Math.max(...scr.map((p) => p[0]))]; + const [sy0, sy1] = [Math.min(...scr.map((p) => p[1])), Math.max(...scr.map((p) => p[1]))]; + const raw = Math.min( + width / Math.max((sx1 - sx0) * 1.25, 1), + height / Math.max((sy1 - sy0) * 1.25, 1), + ); + const scale = Math.min(Math.max(raw, 1), maxScale); + return { + scale, + tx: width / 2 - (scale * (sx0 + sx1)) / 2, + ty: height / 2 - (scale * (sy0 + sy1)) / 2, + }; +} + /** 결과 좌표 → 화면 px */ export function toScreen(fit: ViewFit, p: Point): Point { return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; @@ -336,9 +373,9 @@ export function forceArrows( return [ arrow("W", slice.weight, centroid, [0, -1]), arrow("Q", slice.surcharge, midTop, [0, -1], true), - arrow("N", slice.N_eff, midBase, n), - arrow("U", slice.U, midBase, n), - arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]]), + arrow("N", slice.N_eff, midBase, n, true), + arrow("U", slice.U, midBase, n, true), + arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]], true), arrow("EL", slice.E_left, nearFace, [s, 0], true), arrow("ER", slice.E_right, farFace, [-s, 0], true), arrow("XL", slice.X_left, nearFace, [0, 1], true), diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 40f2fae3c..6e88d4d9b 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -50,6 +50,8 @@ export const ui_locales_e03 = { E03_Error: ["오류", "Error"], E03_Cut: ["깎기", "Cut"], E03_Fill: ["쌓기", "Fill"], + E03_CutShort: ["깎", "C"], + E03_FillShort: ["쌓", "F"], E03_Left: ["좌 비탈", "Left slope"], E03_Right: ["우 비탈", "Right slope"], E03_Side: ["비탈", "Slope"],