fix(532-3): E03 일꾼 stderr 파일로(파이프 막힘으로 해석 멈춤) · 처음 보기 = 그 비탈 확대 · 화면 높이 고정 · 자유물체도 반력 화살 · 목록 줄임

- 일꾼 stderr 를 파이프로 두면 자식 경고가 쌓여 8/57 에서 멈춤 → 임시 파일로
- 횡단 처음 보기 = 탐색 범위 · 임계면 · 격자 중심을 판 가운데(두 번 누름 = 이 보기 · 줌 아웃 = 모형 전체)
- 본문 높이 = 화면 높이(커지며 넘치던 것) · 고른 줄 표시 밝음/어두움 같이 · 3D 보기 창 맨 위
- 자유물체도 N · U · S = 바닥으로 들어오는 화살 · 글 = 화살 바깥 끝
- 목록 측점 0+000 꼴 · 좌우 깎/쌓 · 해석 없음 = -

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 21:01:50 +09:00
co-authored by Claude Opus 5.5
parent ecd027f028
commit 8348e6b922
9 changed files with 98 additions and 32 deletions
+20 -15
View File
@@ -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:
+9 -3
View File
@@ -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),
}),
],
}),
@@ -331,7 +331,9 @@ export function renderE03StabSection(root: HTMLElement): void {
}
async function load(): Promise<void> {
const before = result?.status;
result = await api<Result2d>(`${base}/result2d`);
if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로
sections.clear();
const conds = result.conditions;
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
+9 -2
View File
@@ -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)}`,
),
);
@@ -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<K extends keyof SVGElementTagNameMap>(
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),
});
+10 -6
View File
@@ -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);
+1 -1
View File
@@ -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();
+40 -3
View File
@@ -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),
+2
View File
@@ -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"],