diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 6f74b9983..640890541 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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); } } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 56c2499c6..94dc53ea0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f5577ca81..769e7ce9f 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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); diff --git a/resources/tester/helper_190_1_basin_pick.cjs b/resources/tester/helper_190_1_basin_pick.cjs new file mode 100644 index 000000000..a09a1949b --- /dev/null +++ b/resources/tester/helper_190_1_basin_pick.cjs @@ -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)); diff --git a/resources/tester/test_190_1_basin_pick.py b/resources/tester/test_190_1_basin_pick.py new file mode 100644 index 000000000..cea785514 --- /dev/null +++ b/resources/tester/test_190_1_basin_pick.py @@ -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