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

This commit is contained in:
2026-10-10 13:23:06 +09:00
5 changed files with 307 additions and 26 deletions
+16 -14
View File
@@ -21,6 +21,7 @@ import type {
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
import {
appendChuteBand,
@@ -371,20 +372,21 @@ export function appendCulvertOverlay(
layer.append(hit);
}
// 집수정도 선택 대상(2026-08-22 사용자 — 형식 드롭다운). 자리 이동은 없고
// 조정창에서 구조물 형식만 고른다.
if (layout.basin) {
for (const part of layout.basin.parts) {
const hit = polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
"유입 집수정 선택 — 조정창에서 구조물 형식 선택",
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectRevet("inlet");
});
layer.append(hit);
}
// 조정창에서 구조물 형식만 고른다. 판정면 = 부재 전체를 감싸는 가상 사각형(190-1 · 세월교 측벽 꼴).
const basinPoints = layout.basin?.parts.flatMap((part) => part.points) ?? [];
if (basinPoints.length) {
const hit = polygon(
outlineBox(basinPoints).map(
(p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number],
),
"b06-chart__culvert-revet-hit",
"유입 집수정 선택 — 조정창에서 구조물 형식 선택",
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectRevet("inlet");
});
layer.append(hit);
}
}
@@ -36,6 +36,20 @@ export interface OffsetPoint {
elevation: number;
}
/** 점들을 감싸는 가상 사각형 — 고르기 판정면만(그림 아님) · 세월교 측벽 ㄴ(79-6) · 집수정(190-1). */
export function outlineBox(points: OffsetPoint[]): OffsetPoint[] {
const offsets = points.map((point) => point.offset);
const elevations = points.map((point) => point.elevation);
const [left, right] = [Math.max(...offsets), Math.min(...offsets)];
const [top, bottom] = [Math.max(...elevations), Math.min(...elevations)];
return [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
}
/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */
export interface CulvertDesignTrim {
minOffset: number;
+2 -12
View File
@@ -10,7 +10,7 @@
* ========================================================================== */
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
export {
@@ -187,17 +187,7 @@ export function appendFordOverlay(
for (const side of layout.sides) {
const points = side.parts.flatMap((part) => part.points);
if (!points.length) continue;
const offsets = points.map((point) => point.offset);
const elevations = points.map((point) => point.elevation);
const [left, right] = [Math.max(...offsets), Math.min(...offsets)];
const [top, bottom] = [Math.max(...elevations), Math.min(...elevations)];
const box = [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
const hit = polygon(box, "b06-chart__culvert-revet-hit", "");
const hit = polygon(outlineBox(points), "b06-chart__culvert-revet-hit", "");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall(side.role);
@@ -0,0 +1,190 @@
/* PLAN 190-1 단위검증 헬퍼 — B06 집수정 판정면 = 부재 외곽 가상 사각형(세월교 측벽 꼴).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점 + design.inlet_structure 강제(U · L · I).
* 짝 = `test_190_1_basin_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
// ── SVG 그릇 흉내 — 오버레이가 쓰는 만큼만 ─────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.attrs = {};
this.listeners = {};
this.textContent = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
appendChild(kid) {
this.children.push(kid);
return kid;
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
getAttribute(name) {
return this.attrs[name] ?? null;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
click() {
let stopped = false;
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
return stopped;
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
const { outlineBox } = loadTs(B06("B06_Section_UI_Cross_Culvert_Types"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
const BASIN_TIP = "유입 집수정 선택 — 조정창에서 구조물 형식 선택";
const X = (o) => -o * 10;
const Y = (e) => -e * 10;
const pointsOf = (node) => node.attrs.points.split(" ").map((pair) => pair.split(",").map(Number));
function basinCase(shape) {
const target = { ...pipeSection, design: { ...pipeSection.design, inlet_structure: shape } };
const layout = computeStoredLayouts(target, sections).culvert;
const layer = new FakeNode("g");
const picked = [];
const setter = appendCulvertOverlay(layer, layout, X, Y, (key) => picked.push(key));
const hits = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit"));
const basinHits = hits.filter((n) => n.children[0]?.textContent === BASIN_TIP);
const all = layout.basin.parts.flatMap((part) => part.points);
// 기대 외곽(화면 좌표) — 부재 전 꼭짓점의 최소 · 최대
const xs = all.map((p) => X(p.offset));
const ys = all.map((p) => Y(p.elevation));
const expect = {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
const got = basinHits.length ? pointsOf(basinHits[0]) : [];
const gotBox = got.length
? {
minX: Math.min(...got.map(([px]) => px)),
maxX: Math.max(...got.map(([px]) => px)),
minY: Math.min(...got.map(([, py]) => py)),
maxY: Math.max(...got.map(([, py]) => py)),
}
: null;
const stopped = basinHits.length ? basinHits[0].click() : false;
// 그림 모양은 그대로 — 집수정 부재 폴리곤 = 부재 점열 그대로 · 고르면 부재 강조
const drawn = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-basin"));
const drawnSame = drawn.every(
(n, i) =>
n.attrs.points ===
layout.basin.parts[i].points.map((p) => `${X(p.offset)},${Y(p.elevation)}`).join(" "),
);
setter("inlet");
const activeOnInlet = drawn.every((n) => n.classList.contains("is-active"));
return {
shape: layout.basin.shape,
parts: layout.basin.parts.length,
basinHits: basinHits.length,
corners: got.length,
expect,
got: gotBox,
picked,
stopped,
lastHitIsBasin: hits[hits.length - 1] === basinHits[0],
drawnParts: drawn.length,
drawnSame,
activeOnInlet,
};
}
const out = { U: basinCase("U"), L: basinCase("L"), I: basinCase("I") };
// 세월교 측벽과 같은 함수 — ㄴ 점열의 외곽
const ell = [
{ offset: 0, elevation: 0 },
{ offset: 0.3, elevation: 0 },
{ offset: 0.3, elevation: 2 },
{ offset: 1.5, elevation: 2 },
{ offset: 1.5, elevation: 2.3 },
{ offset: 0, elevation: 2.3 },
];
out.outline = outlineBox(ell);
// 판정면 없으면(onSelect 없음 · B07) 집수정 판정면도 없음
{
const target = { ...pipeSection, design: { ...pipeSection.design, inlet_structure: "U" } };
const layout = computeStoredLayouts(target, sections).culvert;
const layer = new FakeNode("g");
appendCulvertOverlay(layer, layout, X, Y);
out.noSelectHits = layer.children.filter((n) =>
n.classList.contains("b06-chart__culvert-revet-hit"),
).length;
}
process.stdout.write(JSON.stringify(out));
+85
View File
@@ -0,0 +1,85 @@
"""PLAN 190-1 — B06 집수정 고르기 판정면 = 부재 외곽 가상 사각형(세월교 측벽 ㄴ 꼴 · 79-6).
짝: `B06_Section/B06_Section_UI_Cross_Culvert.ts`(집수정 판정면) · `_Cross_Ford.ts`(측벽 판정면) ·
`_Cross_Culvert_Types.ts`(`outlineBox` 한 벌). [상세] 모달은 카드 그림 복제라 같은 판정면을 가짐.
실제 TS 를 `helper_190_1_basin_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_190_1_basin_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _src(rel: str) -> str:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
return f.read()
@pytest.mark.parametrize("shape,parts", [("U", 3), ("L", 2), ("I", 1)])
def test_집수정_판정면은_외곽_사각_하나(out, shape, parts) -> None:
case = out[shape]
assert case["shape"] == shape and case["parts"] == parts
assert case["basinHits"] == 1 # 부재마다가 아니라 한 장
assert case["corners"] == 4
for key in ("minX", "maxX", "minY", "maxY"):
assert case["got"][key] == pytest.approx(case["expect"][key], abs=1e-9)
@pytest.mark.parametrize("shape", ["U", "L", "I"])
def test_누르면_집수정_고름(out, shape) -> None:
case = out[shape]
assert case["picked"] == ["inlet"]
assert case["stopped"] is True # 카드 고름으로 안 번짐
assert case["lastHitIsBasin"] is True # 맨 위 — 관 판정면보다 위
@pytest.mark.parametrize("shape", ["U", "L", "I"])
def test_그림_모양은_그대로(out, shape) -> None:
case = out[shape]
assert case["drawnParts"] == case["parts"]
assert case["drawnSame"] is True
assert case["activeOnInlet"] is True
def test_외곽_사각_함수(out) -> None:
box = [(p["offset"], p["elevation"]) for p in out["outline"]]
assert box == [(1.5, 2.3), (0, 2.3), (0, 0), (1.5, 0)]
def test_고름_없으면_판정면_없음(out) -> None:
assert out["noSelectHits"] == 0 # B07 등 onSelect 없는 그림
def test_세월교_측벽과_같은_함수() -> None:
types = _src("B06_Section/B06_Section_UI_Cross_Culvert_Types.ts")
assert "export function outlineBox(points: OffsetPoint[]): OffsetPoint[] {" in types
ford = _src("B06_Section/B06_Section_UI_Cross_Ford.ts")
assert 'polygon(outlineBox(points), "b06-chart__culvert-revet-hit", "")' in ford
assert "Math.max(...elevations)" not in ford # 측벽 외곽 따로 셈 없음
culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts")
assert "outlineBox(basinPoints)" in culvert